@scrylog/cli 0.1.4 → 0.3.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 +46 -7
- package/package.json +13 -10
- package/src/api/docs/sessions.ts +3 -2
- package/src/api/docs/system.ts +3 -0
- package/src/api/docs/usage.ts +2 -2
- package/src/api/routes/sessions.ts +6 -2
- package/src/api/routes/system.ts +4 -1
- package/src/api/routes/webhooks/claude.ts +9 -21
- package/src/api/routes/webhooks/opencode.ts +6 -1
- package/src/api/routes.ts +1 -1
- package/src/api/schemas/common.ts +8 -0
- package/src/api/schemas/sessions.ts +15 -1
- package/src/api/schemas/usage.ts +1 -0
- package/src/cli/commands.ts +170 -26
- package/src/cli/index.ts +18 -0
- package/src/cli/program.ts +175 -0
- package/src/daemon/collectors/claude/collector.ts +11 -1
- package/src/daemon/collectors/claude/discovery.ts +2 -23
- package/src/daemon/collectors/codex/collector.ts +3 -3
- package/src/daemon/collectors/git.ts +60 -2
- package/src/daemon/collectors/herdr/collector.ts +128 -0
- package/src/daemon/collectors/herdr/index.ts +2 -0
- package/src/daemon/collectors/herdr/normalization.ts +111 -0
- package/src/daemon/collectors/opencode/collector.ts +4 -2
- package/src/daemon/collectors/opencode-db.ts +17 -5
- package/src/daemon/collectors/tmux/collector.ts +21 -1
- package/src/daemon/collectors/tmux/enrichment.ts +1 -0
- package/src/daemon/collectors/usage.ts +115 -49
- package/src/daemon/index.ts +2 -0
- package/src/daemon/store.ts +109 -14
- package/src/postinstall.cjs +11 -0
- package/src/shared/config.ts +33 -0
- package/src/shared/session-exclusions.ts +5 -0
- package/src/shared/session-view.ts +67 -6
- package/src/shared/terminal-capabilities.ts +130 -0
- package/src/shared/types.ts +33 -2
- package/src/shared/version.ts +44 -0
- package/src/tui/App.tsx +240 -0
- package/src/tui/assets/providers/SOURCES.md +21 -0
- package/src/tui/assets/providers/claude-terminal.png +0 -0
- package/src/tui/assets/providers/claude-terminal.svg +62 -0
- package/src/tui/assets/providers/claude.png +0 -0
- package/src/tui/assets/providers/copilot.png +0 -0
- package/src/tui/assets/providers/copilot.svg +44 -0
- package/src/tui/assets/providers/openai-terminal.png +0 -0
- package/src/tui/assets/providers/openai-terminal.svg +62 -0
- package/src/tui/assets/providers/openai.png +0 -0
- package/src/tui/assets/providers/opencode-terminal.png +0 -0
- package/src/tui/assets/providers/opencode-terminal.svg +62 -0
- package/src/tui/assets/providers/opencode.png +0 -0
- package/src/tui/assets/providers/shell.png +0 -0
- package/src/tui/assets/providers/shell.svg +23 -0
- package/src/tui/assets/providers/unknown.png +0 -0
- package/src/tui/assets/providers/unknown.svg +23 -0
- package/src/tui/client.ts +22 -8
- package/src/tui/components/BindingsModal.tsx +82 -0
- package/src/tui/components/DashboardHeader.tsx +256 -0
- package/src/tui/components/KeyBar.tsx +67 -0
- package/src/tui/components/SessionDetailModal.tsx +169 -0
- package/src/tui/components/SessionsView.tsx +184 -0
- package/src/tui/components/TabBar.tsx +40 -0
- package/src/tui/components/UsageSummaryPane.tsx +272 -0
- package/src/tui/components/UsageView.tsx +242 -0
- package/src/tui/components/tool-icons.ts +24 -0
- package/src/tui/index.tsx +106 -0
- package/src/tui/keybindings.ts +49 -0
- package/src/tui/model.ts +295 -0
- package/src/tui/state.ts +77 -0
- package/src/tui/theme.ts +110 -0
- package/src/usage/README.md +3 -3
- package/src/usage/clients/anthropic-oauth-usage.ts +100 -20
- package/src/usage/clients/anthropic-web-usage.ts +133 -49
- package/src/usage/clients/claude-cli-probe.ts +458 -132
- package/src/usage/clients/codex-app-server.ts +354 -0
- package/src/usage/clients/codex-oauth-usage.ts +143 -16
- package/src/usage/clients/opencode-go-api.ts +100 -0
- package/src/usage/clients/opencode-go-usage.ts +163 -27
- package/src/usage/errors.ts +33 -0
- package/src/usage/index.ts +2 -1
- package/src/usage/local/opencode-go-local.ts +194 -0
- package/src/usage/mappers.ts +24 -15
- package/src/usage/planner.ts +89 -25
- package/src/usage/providers/claude.ts +203 -83
- package/src/usage/providers/codex.ts +92 -11
- package/src/usage/providers/opencode-go.ts +94 -16
- package/src/usage/resolvers/browser-cookies.ts +679 -69
- package/src/usage/resolvers/claude-auth.ts +150 -41
- package/src/usage/resolvers/claude-desktop-auth.ts +170 -0
- package/src/usage/resolvers/claude-web.ts +72 -6
- package/src/usage/resolvers/codex-auth.ts +74 -17
- package/src/usage/resolvers/keychain-coordinator.ts +397 -0
- package/src/usage/resolvers/opencode-go-web.ts +47 -6
- package/src/usage/types.ts +37 -2
- package/src/usage/web-session-cache.ts +37 -0
- package/src/neo-blessed.d.ts +0 -4
- package/src/tui/app.ts +0 -920
- package/src/tui/index.ts +0 -193
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { listSessions } from "@joeldmtz/prtl-core";
|
|
2
|
+
import type { ListSessionsOptions, PrtlListResult } from "@joeldmtz/prtl-core";
|
|
3
|
+
import type { ScrylogConfig } from "../../../shared/config";
|
|
4
|
+
import type { SessionModel } from "../../../shared/types";
|
|
5
|
+
import type { Logger } from "../../../shared/logger";
|
|
6
|
+
import { NOOP_LOGGER } from "../../../shared/logger";
|
|
7
|
+
import type { Collector } from "../../scheduler";
|
|
8
|
+
import type { DaemonStore } from "../../store";
|
|
9
|
+
import type { GitCollector } from "../git";
|
|
10
|
+
import { herdrPaneToSession } from "./normalization";
|
|
11
|
+
|
|
12
|
+
type HerdrList = (options?: ListSessionsOptions) => Promise<PrtlListResult>;
|
|
13
|
+
|
|
14
|
+
export class HerdrCollector implements Collector {
|
|
15
|
+
name = "herdr";
|
|
16
|
+
private timer: ReturnType<typeof setInterval> | null = null;
|
|
17
|
+
private inFlight = false;
|
|
18
|
+
private generation = 0;
|
|
19
|
+
private logger: Logger = NOOP_LOGGER;
|
|
20
|
+
|
|
21
|
+
constructor(
|
|
22
|
+
private readonly gitCollector: GitCollector,
|
|
23
|
+
private readonly list: HerdrList = listSessions,
|
|
24
|
+
) {}
|
|
25
|
+
|
|
26
|
+
start(store: DaemonStore, config: ScrylogConfig, logger: Logger): void {
|
|
27
|
+
this.logger = logger;
|
|
28
|
+
const generation = ++this.generation;
|
|
29
|
+
const run = () => void this.collect(store, config.daemon.commandTimeoutMs, generation);
|
|
30
|
+
logger.info("herdr", "Collector started", { event: "collector.started", collector: this.name, intervalMs: config.daemon.pollIntervalMs });
|
|
31
|
+
run();
|
|
32
|
+
this.timer = setInterval(run, config.daemon.pollIntervalMs);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
stop(): void {
|
|
36
|
+
this.generation += 1;
|
|
37
|
+
if (this.timer) {
|
|
38
|
+
clearInterval(this.timer);
|
|
39
|
+
this.timer = null;
|
|
40
|
+
}
|
|
41
|
+
this.logger.info("herdr", "Collector stopped", { event: "collector.stopped", collector: this.name });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async collect(store: DaemonStore, timeoutMs: number, generation = this.generation): Promise<void> {
|
|
45
|
+
if (this.inFlight) {
|
|
46
|
+
this.logger.debug("herdr", "Skipping overlapping poll", { event: "collector.poll_skipped", collector: this.name });
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
this.inFlight = true;
|
|
51
|
+
const startedAt = Date.now();
|
|
52
|
+
try {
|
|
53
|
+
const result = await this.list({ provider: "herdr", timeoutMs });
|
|
54
|
+
if (generation !== this.generation) return;
|
|
55
|
+
const provider = result.metadata.providers.herdr;
|
|
56
|
+
const liveHerdrIds = new Set<string>();
|
|
57
|
+
|
|
58
|
+
if (provider?.available) {
|
|
59
|
+
for (const pane of result.items.filter((item) => item.provider === "herdr")) {
|
|
60
|
+
const path = pane.foregroundCwd ?? pane.cwd;
|
|
61
|
+
const session = herdrPaneToSession(pane, path ? this.gitCollector.resolve(path) : undefined);
|
|
62
|
+
liveHerdrIds.add(session.id);
|
|
63
|
+
const existing = store.getSession(session.id);
|
|
64
|
+
if (existing && existing.detectionSource !== "herdr") {
|
|
65
|
+
if (existing.source !== "tmux") {
|
|
66
|
+
store.upsertSession({
|
|
67
|
+
...existing,
|
|
68
|
+
source: "herdr",
|
|
69
|
+
herdr: session.herdr,
|
|
70
|
+
project: session.project ?? existing.project,
|
|
71
|
+
currentPath: existing.currentPath ?? session.currentPath,
|
|
72
|
+
currentCommand: session.currentCommand,
|
|
73
|
+
paneTitle: session.paneTitle,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
} else {
|
|
77
|
+
// herdr-native sessions: prtl-core refreshes metadata.collectedAt on
|
|
78
|
+
// every collection, so a pane doing nothing would report a fresh
|
|
79
|
+
// lastActivity each poll and constantly reorder idle groups. Only
|
|
80
|
+
// real changes (state, agent status, summary) count as activity.
|
|
81
|
+
const previous = existing?.detectionSource === "herdr" ? existing : undefined;
|
|
82
|
+
const activityChanged = Boolean(
|
|
83
|
+
previous
|
|
84
|
+
&& (previous.state !== session.state
|
|
85
|
+
|| previous.herdr?.agentStatus !== session.herdr?.agentStatus
|
|
86
|
+
|| previous.summary !== session.summary),
|
|
87
|
+
);
|
|
88
|
+
const merged: SessionModel = previous && !activityChanged
|
|
89
|
+
? { ...session, lastActivity: previous.lastActivity }
|
|
90
|
+
: { ...session, lastActivity: Date.now() };
|
|
91
|
+
store.upsertSession(merged);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
for (const session of store.getSessions()) {
|
|
96
|
+
if (session.source !== "herdr" || liveHerdrIds.has(session.id)) continue;
|
|
97
|
+
if (session.detectionSource === "herdr") {
|
|
98
|
+
store.removeSession(session.id);
|
|
99
|
+
} else {
|
|
100
|
+
store.clearHerdrBinding(session.id);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const errors = provider?.errors?.map((error) => `${error.source}: ${error.message}`) ?? [];
|
|
106
|
+
const lastError = provider?.available ? undefined : (provider?.reason ?? errors.at(-1) ?? "herdr_unavailable");
|
|
107
|
+
store.setCollectorStatus(this.name, {
|
|
108
|
+
running: true,
|
|
109
|
+
lastRunAt: Date.now(),
|
|
110
|
+
lastDurationMs: Date.now() - startedAt,
|
|
111
|
+
lastError,
|
|
112
|
+
});
|
|
113
|
+
this.logger.debug("herdr", "Collection completed", {
|
|
114
|
+
event: "collector.poll_completed",
|
|
115
|
+
collector: this.name,
|
|
116
|
+
available: provider?.available ?? false,
|
|
117
|
+
panes: liveHerdrIds.size,
|
|
118
|
+
});
|
|
119
|
+
} catch (error) {
|
|
120
|
+
if (generation !== this.generation) return;
|
|
121
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
122
|
+
store.setCollectorStatus(this.name, { running: true, lastRunAt: Date.now(), lastDurationMs: Date.now() - startedAt, lastError: message });
|
|
123
|
+
this.logger.warn("herdr", "Collection failed", { event: "collector.poll_failed", collector: this.name, errorMessage: message });
|
|
124
|
+
} finally {
|
|
125
|
+
this.inFlight = false;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type { PrtlPane } from "@joeldmtz/prtl-core";
|
|
2
|
+
import type { ProjectInfo, SessionModel, SessionState, SessionTool } from "../../../shared/types";
|
|
3
|
+
|
|
4
|
+
const SESSION_TOOLS = new Set<SessionTool>(["opencode", "claude", "codex"]);
|
|
5
|
+
|
|
6
|
+
function normalizeAgentName(value: string): string {
|
|
7
|
+
return value.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function herdrTool(pane: PrtlPane): SessionTool {
|
|
11
|
+
const candidates = [pane.agent?.session?.agent, pane.agent?.name, pane.agent?.session?.source?.split(":").at(-1)];
|
|
12
|
+
for (const candidate of candidates) {
|
|
13
|
+
if (!candidate) continue;
|
|
14
|
+
const normalized = normalizeAgentName(candidate);
|
|
15
|
+
if (normalized === "opencode") return "opencode";
|
|
16
|
+
if (normalized === "claude" || normalized === "claudecode") return "claude";
|
|
17
|
+
if (normalized === "codex" || normalized === "codexcli") return "codex";
|
|
18
|
+
if (normalized === "copilot" || normalized === "copilotcli" || normalized === "githubcopilot") return "copilotcli";
|
|
19
|
+
}
|
|
20
|
+
if (!pane.agent && pane.command) return "shell";
|
|
21
|
+
return "unknown";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function herdrState(pane: PrtlPane, tool = herdrTool(pane)): SessionState {
|
|
25
|
+
if (!pane.agent) return tool === "shell" ? "shell" : "unknown";
|
|
26
|
+
switch (pane.agent.status?.toLowerCase()) {
|
|
27
|
+
case "working": return "working";
|
|
28
|
+
case "idle":
|
|
29
|
+
case "done": return "idle";
|
|
30
|
+
case "blocked": return "waiting-input";
|
|
31
|
+
default: return "unknown";
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function herdrSessionId(pane: PrtlPane, tool = herdrTool(pane)): string {
|
|
36
|
+
const agentSessionId = pane.agent?.sessionId ?? pane.agent?.session?.value;
|
|
37
|
+
return agentSessionId && SESSION_TOOLS.has(tool)
|
|
38
|
+
? `${tool}:${agentSessionId}`
|
|
39
|
+
: `herdr:${pane.id}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function herdrSummary(pane: PrtlPane): string {
|
|
43
|
+
const statusLabel = pane.agent?.status ? pane.stateLabels?.[pane.agent.status] : undefined;
|
|
44
|
+
return pane.customStatus
|
|
45
|
+
?? statusLabel
|
|
46
|
+
?? pane.title
|
|
47
|
+
?? pane.terminalTitleStripped
|
|
48
|
+
?? pane.displayAgent
|
|
49
|
+
?? pane.command
|
|
50
|
+
?? pane.agent?.name
|
|
51
|
+
?? pane.tab
|
|
52
|
+
?? pane.workspace
|
|
53
|
+
?? "Herdr pane";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Explicit pane name only. prtl-core's `pane.name` falls back to the agent
|
|
58
|
+
* name when the pane has no label; in that case the pane is unnamed and the
|
|
59
|
+
* label logic must fall back to the tab name instead. The raw herdr record
|
|
60
|
+
* carries the original pane fields, which is the only way to distinguish an
|
|
61
|
+
* explicit name from prtl-core's agent-derived fallback.
|
|
62
|
+
*/
|
|
63
|
+
export function herdrPaneName(pane: PrtlPane): string | undefined {
|
|
64
|
+
const raw = (pane.metadata?.raw as { pane?: { title?: unknown; label?: unknown; name?: unknown } } | undefined)?.pane;
|
|
65
|
+
if (raw) {
|
|
66
|
+
const explicit = [raw.name, raw.title, raw.label].find(
|
|
67
|
+
(value) => typeof value === "string" && value.trim().length > 0,
|
|
68
|
+
);
|
|
69
|
+
return typeof explicit === "string" ? explicit.trim() : undefined;
|
|
70
|
+
}
|
|
71
|
+
// No raw record to discriminate: only trust an explicitly-typed PrtlPane
|
|
72
|
+
// name field (raw pane title/title/label from the provider), never render
|
|
73
|
+
// the agent-derived fallback as a pane name.
|
|
74
|
+
return pane.title?.trim() || undefined;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function herdrPaneToSession(pane: PrtlPane, project?: ProjectInfo): SessionModel {
|
|
78
|
+
const tool = herdrTool(pane);
|
|
79
|
+
const state = herdrState(pane, tool);
|
|
80
|
+
const agentSessionId = pane.agent?.sessionId ?? pane.agent?.session?.value;
|
|
81
|
+
const collectedAt = Date.parse(pane.metadata.collectedAt);
|
|
82
|
+
return {
|
|
83
|
+
id: herdrSessionId(pane, tool),
|
|
84
|
+
source: "herdr",
|
|
85
|
+
tool,
|
|
86
|
+
state,
|
|
87
|
+
project,
|
|
88
|
+
summary: herdrSummary(pane),
|
|
89
|
+
lastActivity: Number.isFinite(collectedAt) ? collectedAt : Date.now(),
|
|
90
|
+
hasAttention: state === "waiting-input",
|
|
91
|
+
activeTool: pane.agent?.name,
|
|
92
|
+
detectionSource: "herdr",
|
|
93
|
+
currentCommand: pane.command,
|
|
94
|
+
currentPath: pane.foregroundCwd ?? pane.cwd,
|
|
95
|
+
paneTitle: pane.title,
|
|
96
|
+
herdr: {
|
|
97
|
+
paneId: pane.id,
|
|
98
|
+
terminalId: pane.terminalId,
|
|
99
|
+
workspaceId: pane.workspaceId,
|
|
100
|
+
workspace: pane.workspace,
|
|
101
|
+
tabId: pane.tabId,
|
|
102
|
+
tab: pane.tab,
|
|
103
|
+
name: herdrPaneName(pane),
|
|
104
|
+
focused: pane.focused,
|
|
105
|
+
providerSource: pane.metadata.source,
|
|
106
|
+
agent: pane.agent?.name,
|
|
107
|
+
agentStatus: pane.agent?.status,
|
|
108
|
+
agentSessionId,
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|
|
@@ -258,8 +258,9 @@ export class OpenCodeCollector implements Collector {
|
|
|
258
258
|
inst.url,
|
|
259
259
|
inst.pendingPermissions,
|
|
260
260
|
inst.pendingQuestions,
|
|
261
|
+
this.gitCollector,
|
|
261
262
|
);
|
|
262
|
-
const merged: SessionModel = { ...existing, state: updated.state, hasAttention: updated.hasAttention };
|
|
263
|
+
const merged: SessionModel = { ...existing, state: updated.state, hasAttention: updated.hasAttention, project: updated.project ?? existing.project };
|
|
263
264
|
inst.sessions.set(props.sessionID, merged);
|
|
264
265
|
store.upsertSession(merged);
|
|
265
266
|
if (prevState !== merged.state) {
|
|
@@ -385,8 +386,9 @@ export class OpenCodeCollector implements Collector {
|
|
|
385
386
|
inst.url,
|
|
386
387
|
inst.pendingPermissions,
|
|
387
388
|
inst.pendingQuestions,
|
|
389
|
+
this.gitCollector,
|
|
388
390
|
);
|
|
389
|
-
const merged: SessionModel = { ...existing, state: updated.state, hasAttention: updated.hasAttention };
|
|
391
|
+
const merged: SessionModel = { ...existing, state: updated.state, hasAttention: updated.hasAttention, project: updated.project ?? existing.project };
|
|
390
392
|
inst.sessions.set(sessionId, merged);
|
|
391
393
|
store.upsertSession(merged);
|
|
392
394
|
if (prevState !== merged.state) {
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* existing sessions in the store. Runs every 30 seconds.
|
|
6
6
|
*
|
|
7
7
|
* Priority rules:
|
|
8
|
-
* -
|
|
8
|
+
* - Preserves state from native collectors; a matching herdr placeholder is claimed as idle.
|
|
9
9
|
* - If a session already exists (from SSE/webhook), merges title/directory/lastActivity.
|
|
10
10
|
* - If a session does NOT exist yet, adds it with state="idle" and detectionSource="opencode-db".
|
|
11
11
|
*/
|
|
@@ -39,6 +39,16 @@ export function inferChildSessionState(
|
|
|
39
39
|
return "working";
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
export function claimHerdrSessionForOpenCodeDb(existing: SessionModel, sessionId: string): SessionModel {
|
|
43
|
+
if (existing.detectionSource !== "herdr") return existing;
|
|
44
|
+
return {
|
|
45
|
+
...existing,
|
|
46
|
+
state: "idle",
|
|
47
|
+
detectionSource: "opencode-db",
|
|
48
|
+
opencode: existing.opencode ?? { sessionId, instanceUrl: "" },
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
42
52
|
// ── DB path resolution ────────────────────────────────────────────────────────
|
|
43
53
|
|
|
44
54
|
/**
|
|
@@ -202,6 +212,8 @@ export class OpenCodeDbCollector implements Collector {
|
|
|
202
212
|
const existing = store.getSession(storeId);
|
|
203
213
|
|
|
204
214
|
if (existing) {
|
|
215
|
+
const replacingHerdr = existing.detectionSource === "herdr";
|
|
216
|
+
const owned = claimHerdrSessionForOpenCodeDb(existing, row.id);
|
|
205
217
|
// Merge-only: preserve state, just update metadata
|
|
206
218
|
const newParentSessionId = row.parent_id ? `opencode:${row.parent_id}` : undefined;
|
|
207
219
|
const effectiveParentSessionId = newParentSessionId ?? existing.parentSessionId;
|
|
@@ -211,19 +223,19 @@ export class OpenCodeDbCollector implements Collector {
|
|
|
211
223
|
existing.lastActivity !== row.time_updated ||
|
|
212
224
|
existing.parentSessionId !== newParentSessionId;
|
|
213
225
|
const targetState = inferChildSessionState(
|
|
214
|
-
|
|
226
|
+
owned,
|
|
215
227
|
effectiveParentSessionId ? store.getSession(effectiveParentSessionId) : undefined,
|
|
216
228
|
config,
|
|
217
229
|
now,
|
|
218
230
|
);
|
|
219
231
|
|
|
220
|
-
if (needsUpdate || (targetState !== undefined && targetState !== existing.state)) {
|
|
232
|
+
if (replacingHerdr || needsUpdate || (targetState !== undefined && targetState !== existing.state)) {
|
|
221
233
|
const project = row.directory
|
|
222
234
|
? (this.gitCollector?.resolve(row.directory) ?? existing.project)
|
|
223
235
|
: existing.project;
|
|
224
236
|
const merged: SessionModel = {
|
|
225
|
-
...
|
|
226
|
-
state: targetState ??
|
|
237
|
+
...owned,
|
|
238
|
+
state: targetState ?? owned.state,
|
|
227
239
|
summary: row.title || existing.summary,
|
|
228
240
|
currentPath: row.directory ?? existing.currentPath,
|
|
229
241
|
lastActivity: Math.max(existing.lastActivity, row.time_updated),
|
|
@@ -169,6 +169,7 @@ export class TmuxCollector implements Collector {
|
|
|
169
169
|
tmux: {
|
|
170
170
|
session: pane.sessionName,
|
|
171
171
|
window: String(pane.windowIndex),
|
|
172
|
+
windowName: pane.windowName,
|
|
172
173
|
pane: pane.paneId,
|
|
173
174
|
},
|
|
174
175
|
legacyPaneId: pane.paneId,
|
|
@@ -230,6 +231,7 @@ export class TmuxCollector implements Collector {
|
|
|
230
231
|
tmux: {
|
|
231
232
|
session: pane.sessionName,
|
|
232
233
|
window: String(pane.windowIndex),
|
|
234
|
+
windowName: pane.windowName,
|
|
233
235
|
pane: pane.paneId,
|
|
234
236
|
},
|
|
235
237
|
legacyPaneId: pane.paneId,
|
|
@@ -295,6 +297,7 @@ export class TmuxCollector implements Collector {
|
|
|
295
297
|
tmux: {
|
|
296
298
|
session: pane.sessionName,
|
|
297
299
|
window: String(pane.windowIndex),
|
|
300
|
+
windowName: pane.windowName,
|
|
298
301
|
pane: pane.paneId,
|
|
299
302
|
},
|
|
300
303
|
legacyPaneId: pane.paneId,
|
|
@@ -365,6 +368,7 @@ export class TmuxCollector implements Collector {
|
|
|
365
368
|
tmux: {
|
|
366
369
|
session: pane.sessionName,
|
|
367
370
|
window: String(pane.windowIndex),
|
|
371
|
+
windowName: pane.windowName,
|
|
368
372
|
pane: pane.paneId,
|
|
369
373
|
},
|
|
370
374
|
legacyPaneId: pane.paneId,
|
|
@@ -416,6 +420,7 @@ export class TmuxCollector implements Collector {
|
|
|
416
420
|
tmux: {
|
|
417
421
|
session: pane.sessionName,
|
|
418
422
|
window: String(pane.windowIndex),
|
|
423
|
+
windowName: pane.windowName,
|
|
419
424
|
pane: pane.paneId,
|
|
420
425
|
},
|
|
421
426
|
legacyPaneId: pane.paneId,
|
|
@@ -467,6 +472,21 @@ export class TmuxCollector implements Collector {
|
|
|
467
472
|
(s) => !(s.source === "tmux" && s.detectionSource === "fallback-detection"),
|
|
468
473
|
);
|
|
469
474
|
|
|
470
|
-
|
|
475
|
+
// Fallback sessions are rebuilt from scratch every poll. Git project
|
|
476
|
+
// resolution can transiently fail (or currentPath can come through empty),
|
|
477
|
+
// so inherit the previously-resolved project/currentPath to keep the TUI
|
|
478
|
+
// group membership stable instead of oscillating between polls.
|
|
479
|
+
const previousFallback = new Map(
|
|
480
|
+
current
|
|
481
|
+
.filter((s) => s.source === "tmux" && s.detectionSource === "fallback-detection")
|
|
482
|
+
.map((s) => [s.id, s]),
|
|
483
|
+
);
|
|
484
|
+
const mergedFallbacks = newFallbackSessions.map((session) => {
|
|
485
|
+
const prev = previousFallback.get(session.id);
|
|
486
|
+
if (!prev || session.project) return session;
|
|
487
|
+
return { ...session, project: prev.project, currentPath: session.currentPath || prev.currentPath };
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
store.setSessions([...nonFallback, ...mergedFallbacks]);
|
|
471
491
|
}
|
|
472
492
|
}
|
|
@@ -218,6 +218,7 @@ export function paneModelToSession(pane: ReturnType<typeof classifyPane>["pane"]
|
|
|
218
218
|
tmux: {
|
|
219
219
|
session: pane.sessionName,
|
|
220
220
|
window: String(pane.windowIndex),
|
|
221
|
+
windowName: pane.windowName,
|
|
221
222
|
pane: pane.paneId,
|
|
222
223
|
},
|
|
223
224
|
summary: codexPrompt?.summary ?? claudePrompt?.summary ?? pane.summary,
|
|
@@ -10,9 +10,16 @@ import { ClaudeWebResolver } from "../../usage/resolvers/claude-web";
|
|
|
10
10
|
import { ClaudeCliResolver } from "../../usage/resolvers/claude-cli";
|
|
11
11
|
import { CodexAuthResolver } from "../../usage/resolvers/codex-auth";
|
|
12
12
|
import { OpenCodeGoWebResolver } from "../../usage/resolvers/opencode-go-web";
|
|
13
|
+
import { keychainCoordinator } from "../../usage/resolvers/keychain-coordinator";
|
|
13
14
|
import type { DaemonStore } from "../store";
|
|
14
15
|
import type { Collector } from "../scheduler";
|
|
15
16
|
|
|
17
|
+
interface UsageResolverSet {
|
|
18
|
+
claudeProvider: ClaudeUsageProvider;
|
|
19
|
+
codexProvider: CodexUsageProvider;
|
|
20
|
+
opencodeGoProvider: OpenCodeGoUsageProvider;
|
|
21
|
+
}
|
|
22
|
+
|
|
16
23
|
export class UsageCollector implements Collector {
|
|
17
24
|
name = "usage";
|
|
18
25
|
private timer?: ReturnType<typeof setInterval>;
|
|
@@ -22,11 +29,24 @@ export class UsageCollector implements Collector {
|
|
|
22
29
|
private polling = false;
|
|
23
30
|
private skippedPolls = 0;
|
|
24
31
|
private errorCount = 0;
|
|
32
|
+
private pollController?: AbortController;
|
|
33
|
+
private resolverSet?: UsageResolverSet;
|
|
25
34
|
|
|
26
35
|
async start(store: DaemonStore, config: ScrylogConfig, logger: Logger): Promise<void> {
|
|
27
36
|
this.store = store;
|
|
28
37
|
this.config = config;
|
|
29
38
|
this.logger = logger;
|
|
39
|
+
keychainCoordinator.configure({
|
|
40
|
+
allowPrompts: () => config.usage.auth.allowKeychainPrompts,
|
|
41
|
+
onFailure: (failure) =>
|
|
42
|
+
logger.warn("usage", "Keychain read failed", {
|
|
43
|
+
event: "keychain_read_failed",
|
|
44
|
+
code: failure.code,
|
|
45
|
+
service: failure.service,
|
|
46
|
+
account: failure.account ?? null,
|
|
47
|
+
cooldownUntil: failure.cooldownUntil ? new Date(failure.cooldownUntil).toISOString() : null,
|
|
48
|
+
}),
|
|
49
|
+
});
|
|
30
50
|
|
|
31
51
|
logger.info("usage", "Collector started", { event: "collector.started", collector: this.name, intervalMs: config.usage.pollIntervalMs, providers: config.usage.providers });
|
|
32
52
|
|
|
@@ -40,6 +60,10 @@ export class UsageCollector implements Collector {
|
|
|
40
60
|
clearInterval(this.timer);
|
|
41
61
|
this.timer = undefined;
|
|
42
62
|
}
|
|
63
|
+
this.pollController?.abort();
|
|
64
|
+
// Drop resolver/provider instances so a later start() rebuilds them from
|
|
65
|
+
// the current configuration instead of reusing stale constructor args.
|
|
66
|
+
this.resolverSet = undefined;
|
|
43
67
|
this.logger?.info("usage", "Collector stopped", { event: "collector.stopped", collector: this.name });
|
|
44
68
|
}
|
|
45
69
|
|
|
@@ -53,49 +77,43 @@ export class UsageCollector implements Collector {
|
|
|
53
77
|
}
|
|
54
78
|
|
|
55
79
|
this.polling = true;
|
|
80
|
+
const pollController = new AbortController();
|
|
81
|
+
this.pollController = pollController;
|
|
56
82
|
const startedAt = Date.now();
|
|
57
83
|
try {
|
|
58
|
-
await this.poll();
|
|
84
|
+
await this.poll(pollController.signal);
|
|
59
85
|
store.setCollectorStatus(this.name, { lastDurationMs: Date.now() - startedAt, skippedPolls: this.skippedPolls, errorCount: this.errorCount });
|
|
86
|
+
} catch (error) {
|
|
87
|
+
if (!pollController.signal.aborted) {
|
|
88
|
+
this.errorCount += 1;
|
|
89
|
+
this.logger?.warn("usage", "Poll failed", {
|
|
90
|
+
event: "collector.poll_failed",
|
|
91
|
+
collector: this.name,
|
|
92
|
+
errorMessage: error instanceof Error ? error.message : String(error),
|
|
93
|
+
});
|
|
94
|
+
}
|
|
60
95
|
} finally {
|
|
96
|
+
if (this.pollController === pollController) this.pollController = undefined;
|
|
61
97
|
this.polling = false;
|
|
62
98
|
}
|
|
63
99
|
}
|
|
64
100
|
|
|
65
|
-
private async poll(): Promise<void> {
|
|
101
|
+
private async poll(signal: AbortSignal): Promise<void> {
|
|
66
102
|
const store = this.store!;
|
|
67
103
|
const config = this.config!;
|
|
68
104
|
const logger = this.logger!;
|
|
69
105
|
|
|
106
|
+
const resolvers = this.ensureResolvers(config);
|
|
70
107
|
const providers: UsageProviderEntry[] = [];
|
|
71
108
|
const attemptedAt = Date.now();
|
|
72
109
|
|
|
73
110
|
for (const providerName of config.usage.providers) {
|
|
74
111
|
if (providerName === "claude") {
|
|
75
|
-
const oauthResolver = new ClaudeAuthResolver();
|
|
76
|
-
const webResolver = new ClaudeWebResolver();
|
|
77
|
-
const cliResolver = new ClaudeCliResolver();
|
|
78
|
-
const planner = new ClaudeSourcePlanner(
|
|
79
|
-
{
|
|
80
|
-
dataSource: config.usage.claude.dataSource,
|
|
81
|
-
webExtrasEnabled: config.usage.claude.webExtrasEnabled,
|
|
82
|
-
},
|
|
83
|
-
oauthResolver,
|
|
84
|
-
webResolver,
|
|
85
|
-
cliResolver,
|
|
86
|
-
);
|
|
87
|
-
|
|
88
|
-
const decision = await planner.decide();
|
|
89
|
-
if (decision.primary === "auto") {
|
|
90
|
-
logger.debug("usage", "Claude not available on this machine, omitting from response", { event: "provider.unavailable", provider: "claude" });
|
|
91
|
-
continue;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
const claudeProvider = new ClaudeUsageProvider(planner, oauthResolver, webResolver, cliResolver);
|
|
95
112
|
try {
|
|
96
|
-
const result = await withAbort((
|
|
113
|
+
const result = await withAbort((requestSignal) => resolvers.claudeProvider.fetch({ signal: requestSignal }), config.daemon.networkTimeoutMs, "Claude usage fetch timed out", signal);
|
|
97
114
|
providers.push(result.entry);
|
|
98
115
|
} catch (err) {
|
|
116
|
+
if (signal.aborted) throw err;
|
|
99
117
|
this.errorCount += 1;
|
|
100
118
|
const message = err instanceof Error ? err.message : String(err);
|
|
101
119
|
logger.warn("usage", "Claude provider unexpected error", { event: "provider.fetch_failed", provider: "claude", errorMessage: message });
|
|
@@ -108,19 +126,11 @@ export class UsageCollector implements Collector {
|
|
|
108
126
|
});
|
|
109
127
|
}
|
|
110
128
|
} else if (providerName === "codex") {
|
|
111
|
-
const resolver = new CodexAuthResolver();
|
|
112
|
-
|
|
113
|
-
if (!(await resolver.isAvailable())) {
|
|
114
|
-
logger.debug("usage", "Codex not available on this machine, omitting from response", { event: "provider.unavailable", provider: "codex" });
|
|
115
|
-
continue;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
const provider = new CodexUsageProvider(resolver);
|
|
119
|
-
|
|
120
129
|
try {
|
|
121
|
-
const result = await withAbort((
|
|
130
|
+
const result = await withAbort((requestSignal) => resolvers.codexProvider.fetch({ signal: requestSignal }), config.daemon.networkTimeoutMs, "Codex usage fetch timed out", signal);
|
|
122
131
|
providers.push(result.entry);
|
|
123
132
|
} catch (err) {
|
|
133
|
+
if (signal.aborted) throw err;
|
|
124
134
|
this.errorCount += 1;
|
|
125
135
|
const message = err instanceof Error ? err.message : String(err);
|
|
126
136
|
logger.warn("usage", "Codex provider unexpected error", { event: "provider.fetch_failed", provider: "codex", errorMessage: message });
|
|
@@ -133,19 +143,11 @@ export class UsageCollector implements Collector {
|
|
|
133
143
|
});
|
|
134
144
|
}
|
|
135
145
|
} else if (providerName === "opencode-go") {
|
|
136
|
-
const resolver = new OpenCodeGoWebResolver();
|
|
137
|
-
|
|
138
|
-
if (!(await resolver.isAvailable())) {
|
|
139
|
-
logger.debug("usage", "OpenCode Go not available on this machine, omitting from response", { event: "provider.unavailable", provider: "opencode-go" });
|
|
140
|
-
continue;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
const provider = new OpenCodeGoUsageProvider(resolver, config.usage.opencodeGo.serverFnId);
|
|
144
|
-
|
|
145
146
|
try {
|
|
146
|
-
const result = await withAbort((
|
|
147
|
+
const result = await withAbort((requestSignal) => resolvers.opencodeGoProvider.fetch({ signal: requestSignal }), config.daemon.networkTimeoutMs, "OpenCode Go usage fetch timed out", signal);
|
|
147
148
|
providers.push(result.entry);
|
|
148
149
|
} catch (err) {
|
|
150
|
+
if (signal.aborted) throw err;
|
|
149
151
|
this.errorCount += 1;
|
|
150
152
|
const message = err instanceof Error ? err.message : String(err);
|
|
151
153
|
logger.warn("usage", "OpenCode Go provider unexpected error", { event: "provider.fetch_failed", provider: "opencode-go", errorMessage: message });
|
|
@@ -169,23 +171,87 @@ export class UsageCollector implements Collector {
|
|
|
169
171
|
store.setCollectorStatus(this.name, { running: true, lastRunAt: attemptedAt, skippedPolls: this.skippedPolls, errorCount: this.errorCount });
|
|
170
172
|
logger.debug("usage", "Poll complete", { event: "collector.poll_completed", collector: this.name, providerCount: providers.length });
|
|
171
173
|
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Resolvers and providers are created once per collector lifetime so
|
|
177
|
+
* resolved credentials stay cached across polls instead of re-reading
|
|
178
|
+
* credential sources on every cycle.
|
|
179
|
+
*/
|
|
180
|
+
private ensureResolvers(config: ScrylogConfig): UsageResolverSet {
|
|
181
|
+
if (!this.resolverSet) {
|
|
182
|
+
const oauthResolver = new ClaudeAuthResolver();
|
|
183
|
+
const webResolver = new ClaudeWebResolver();
|
|
184
|
+
const cliResolver = new ClaudeCliResolver();
|
|
185
|
+
const planner = new ClaudeSourcePlanner(
|
|
186
|
+
{
|
|
187
|
+
dataSource: config.usage.claude.dataSource,
|
|
188
|
+
webExtrasEnabled: config.usage.claude.webExtrasEnabled,
|
|
189
|
+
},
|
|
190
|
+
oauthResolver,
|
|
191
|
+
webResolver,
|
|
192
|
+
cliResolver,
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
this.resolverSet = {
|
|
196
|
+
claudeProvider: new ClaudeUsageProvider(planner, oauthResolver, webResolver, cliResolver),
|
|
197
|
+
codexProvider: new CodexUsageProvider(
|
|
198
|
+
new CodexAuthResolver(config.usage.codex.chatgptBaseUrl),
|
|
199
|
+
config.usage.codex.dataSource,
|
|
200
|
+
),
|
|
201
|
+
opencodeGoProvider: new OpenCodeGoUsageProvider(
|
|
202
|
+
new OpenCodeGoWebResolver(),
|
|
203
|
+
config.usage.opencodeGo.serverFnId,
|
|
204
|
+
),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
return this.resolverSet;
|
|
208
|
+
}
|
|
172
209
|
}
|
|
173
210
|
|
|
174
|
-
function withAbort<T>(
|
|
211
|
+
export function withAbort<T>(
|
|
212
|
+
run: (signal: AbortSignal) => Promise<T>,
|
|
213
|
+
timeoutMs: number,
|
|
214
|
+
message: string,
|
|
215
|
+
parentSignal?: AbortSignal,
|
|
216
|
+
): Promise<T> {
|
|
175
217
|
const controller = new AbortController();
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
218
|
+
if (parentSignal?.aborted) {
|
|
219
|
+
controller.abort();
|
|
220
|
+
return Promise.reject(new Error("Usage poll cancelled"));
|
|
221
|
+
}
|
|
222
|
+
return new Promise<T>((resolve, reject) => {
|
|
223
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
224
|
+
const cleanup = () => {
|
|
225
|
+
if (timer) clearTimeout(timer);
|
|
226
|
+
parentSignal?.removeEventListener("abort", onParentAbort);
|
|
227
|
+
};
|
|
228
|
+
const onParentAbort = () => {
|
|
229
|
+
controller.abort();
|
|
230
|
+
reject(new Error("Usage poll cancelled"));
|
|
231
|
+
};
|
|
232
|
+
parentSignal?.addEventListener("abort", onParentAbort, { once: true });
|
|
233
|
+
// Register the deadline before starting the request so the first
|
|
234
|
+
// synchronous step is already bounded.
|
|
235
|
+
timer = setTimeout(() => {
|
|
179
236
|
controller.abort();
|
|
180
237
|
reject(new Error(message));
|
|
181
238
|
}, timeoutMs);
|
|
239
|
+
|
|
240
|
+
let promise: Promise<T>;
|
|
241
|
+
try {
|
|
242
|
+
promise = run(controller.signal);
|
|
243
|
+
} catch (error) {
|
|
244
|
+
cleanup();
|
|
245
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
182
248
|
promise.then(
|
|
183
249
|
(value) => {
|
|
184
|
-
|
|
250
|
+
cleanup();
|
|
185
251
|
resolve(value);
|
|
186
252
|
},
|
|
187
253
|
(error) => {
|
|
188
|
-
|
|
254
|
+
cleanup();
|
|
189
255
|
reject(error);
|
|
190
256
|
},
|
|
191
257
|
);
|
package/src/daemon/index.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { IS_WINDOWS } from "../shared/platform";
|
|
|
6
6
|
import { DaemonStore } from "./store";
|
|
7
7
|
import { Scheduler } from "./scheduler";
|
|
8
8
|
import { TmuxCollector } from "./collectors/tmux";
|
|
9
|
+
import { HerdrCollector } from "./collectors/herdr";
|
|
9
10
|
import { ProcessCollector } from "./collectors/process";
|
|
10
11
|
import { GitCollector } from "./collectors/git";
|
|
11
12
|
import { OpenCodeCollector } from "./collectors/opencode";
|
|
@@ -78,6 +79,7 @@ const gitCollector = new GitCollector();
|
|
|
78
79
|
scheduler.register(processCollector);
|
|
79
80
|
scheduler.register(gitCollector);
|
|
80
81
|
scheduler.register(new TmuxCollector(processCollector, gitCollector));
|
|
82
|
+
scheduler.register(new HerdrCollector(gitCollector));
|
|
81
83
|
if (config.opencode.mode === "sse") {
|
|
82
84
|
scheduler.register(new OpenCodeCollector(gitCollector));
|
|
83
85
|
}
|