@scrylog/cli 0.1.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/README.md +836 -0
- package/package.json +75 -0
- package/src/api/docs/sessions.keys.ts +6 -0
- package/src/api/docs/sessions.ts +81 -0
- package/src/api/docs/system.keys.ts +5 -0
- package/src/api/docs/system.ts +66 -0
- package/src/api/docs/usage.keys.ts +3 -0
- package/src/api/docs/usage.ts +24 -0
- package/src/api/docs/webhooks.keys.ts +5 -0
- package/src/api/docs/webhooks.ts +78 -0
- package/src/api/index.ts +18 -0
- package/src/api/lib/auth.ts +43 -0
- package/src/api/lib/errors.ts +28 -0
- package/src/api/lib/route.ts +18 -0
- package/src/api/models/claude.ts +4 -0
- package/src/api/models/codex.ts +4 -0
- package/src/api/models/opencode.ts +11 -0
- package/src/api/routes/sessions.ts +47 -0
- package/src/api/routes/system.ts +103 -0
- package/src/api/routes/usage.ts +15 -0
- package/src/api/routes/webhooks/claude.ts +161 -0
- package/src/api/routes/webhooks/codex.ts +175 -0
- package/src/api/routes/webhooks/opencode.ts +107 -0
- package/src/api/routes.ts +93 -0
- package/src/api/schemas/common.ts +21 -0
- package/src/api/schemas/sessions.ts +72 -0
- package/src/api/schemas/usage.ts +50 -0
- package/src/api/schemas/webhooks/claude.ts +32 -0
- package/src/api/schemas/webhooks/codex.ts +34 -0
- package/src/api/schemas/webhooks/opencode.ts +29 -0
- package/src/api/services/claude-session.ts +9 -0
- package/src/api/services/codex-session.ts +8 -0
- package/src/api/services/opencode-session.ts +73 -0
- package/src/classifier.ts +206 -0
- package/src/cli/commands.ts +498 -0
- package/src/cli/prompt.ts +44 -0
- package/src/daemon/cli.ts +27 -0
- package/src/daemon/collectors/claude/README.md +108 -0
- package/src/daemon/collectors/claude/collector.ts +413 -0
- package/src/daemon/collectors/claude/discovery.ts +148 -0
- package/src/daemon/collectors/claude/hooks.ts +181 -0
- package/src/daemon/collectors/claude/index.ts +8 -0
- package/src/daemon/collectors/claude/jsonl.ts +85 -0
- package/src/daemon/collectors/claude/transcript.ts +246 -0
- package/src/daemon/collectors/codex/README.md +147 -0
- package/src/daemon/collectors/codex/collector.ts +661 -0
- package/src/daemon/collectors/codex/discovery.ts +104 -0
- package/src/daemon/collectors/codex/hooks.ts +179 -0
- package/src/daemon/collectors/codex/index.ts +7 -0
- package/src/daemon/collectors/codex/jsonl.ts +108 -0
- package/src/daemon/collectors/codex/transcript.ts +299 -0
- package/src/daemon/collectors/git.ts +295 -0
- package/src/daemon/collectors/opencode/README.md +106 -0
- package/src/daemon/collectors/opencode/collector.ts +543 -0
- package/src/daemon/collectors/opencode/discovery.ts +257 -0
- package/src/daemon/collectors/opencode/index.ts +5 -0
- package/src/daemon/collectors/opencode/session-builder.ts +59 -0
- package/src/daemon/collectors/opencode-db.ts +282 -0
- package/src/daemon/collectors/process.ts +201 -0
- package/src/daemon/collectors/tmux/README.md +128 -0
- package/src/daemon/collectors/tmux/collector.ts +472 -0
- package/src/daemon/collectors/tmux/enrichment.ts +241 -0
- package/src/daemon/collectors/tmux/index.ts +4 -0
- package/src/daemon/collectors/tmux/parse.ts +128 -0
- package/src/daemon/collectors/usage.ts +193 -0
- package/src/daemon/index.ts +207 -0
- package/src/daemon/scheduler.ts +86 -0
- package/src/daemon/store.ts +535 -0
- package/src/detect.ts +143 -0
- package/src/hooks/claude.ts +596 -0
- package/src/hooks/codex.ts +517 -0
- package/src/legacy/classifier.ts +215 -0
- package/src/legacy/detect.ts +143 -0
- package/src/mcp/client-server.ts +21 -0
- package/src/mcp/http-handler.ts +126 -0
- package/src/mcp/index.ts +61 -0
- package/src/mcp/resources/config-current.ts +19 -0
- package/src/mcp/resources/index.ts +11 -0
- package/src/mcp/resources/sessions-active.ts +19 -0
- package/src/mcp/resources/sessions-all.ts +19 -0
- package/src/mcp/schemas/sessions.ts +3 -0
- package/src/mcp/server.ts +17 -0
- package/src/mcp/services/api-adapter.ts +48 -0
- package/src/mcp/services/data-source.ts +22 -0
- package/src/mcp/services/store-adapter.ts +40 -0
- package/src/mcp/tools/get-active-sessions.ts +16 -0
- package/src/mcp/tools/get-session-children.ts +24 -0
- package/src/mcp/tools/get-session-messages.ts +24 -0
- package/src/mcp/tools/get-session.ts +23 -0
- package/src/mcp/tools/index.ts +15 -0
- package/src/mcp/tools/list-sessions.ts +16 -0
- package/src/neo-blessed.d.ts +4 -0
- package/src/oc/index.ts +302 -0
- package/src/service/manager.ts +242 -0
- package/src/shared/config.ts +478 -0
- package/src/shared/ipc.ts +80 -0
- package/src/shared/logger.ts +248 -0
- package/src/shared/paths.ts +73 -0
- package/src/shared/platform.ts +110 -0
- package/src/shared/session-view.ts +241 -0
- package/src/shared/types.ts +299 -0
- package/src/shared/utils.ts +339 -0
- package/src/tui/app.ts +920 -0
- package/src/tui/client.ts +149 -0
- package/src/tui/index.ts +181 -0
- package/src/types.ts +113 -0
- package/src/usage/README.md +323 -0
- package/src/usage/api.ts +99 -0
- package/src/usage/client.ts +3 -0
- package/src/usage/clients/anthropic-oauth-usage.ts +178 -0
- package/src/usage/clients/anthropic-web-usage.ts +245 -0
- package/src/usage/clients/claude-cli-probe.ts +187 -0
- package/src/usage/clients/codex-oauth-usage.ts +113 -0
- package/src/usage/clients/opencode-go-usage.ts +441 -0
- package/src/usage/index.ts +19 -0
- package/src/usage/mappers.ts +273 -0
- package/src/usage/planner.ts +54 -0
- package/src/usage/providers/claude.ts +113 -0
- package/src/usage/providers/codex.ts +34 -0
- package/src/usage/providers/opencode-go.ts +37 -0
- package/src/usage/resolver.ts +4 -0
- package/src/usage/resolvers/browser-cookies.ts +198 -0
- package/src/usage/resolvers/claude-auth.ts +99 -0
- package/src/usage/resolvers/claude-cli.ts +24 -0
- package/src/usage/resolvers/claude-web.ts +16 -0
- package/src/usage/resolvers/codex-auth.ts +117 -0
- package/src/usage/resolvers/opencode-go-web.ts +14 -0
- package/src/usage/types.ts +190 -0
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import type { SessionModel, TmuxPaneRecord, PaneSample } from "../../../shared/types";
|
|
2
|
+
import type { OpenCodeConfig } from "../../../shared/config";
|
|
3
|
+
import type { DaemonStore } from "../../store";
|
|
4
|
+
import { classifyPane } from "../../../legacy/classifier";
|
|
5
|
+
import { parseOpencodeUrl } from "../opencode/discovery";
|
|
6
|
+
|
|
7
|
+
export function isOpenCodeManagedSession(session: SessionModel): boolean {
|
|
8
|
+
return session.detectionSource === "opencode-sdk"
|
|
9
|
+
|| session.detectionSource === "opencode-hook"
|
|
10
|
+
|| session.detectionSource === "opencode-db";
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function isClaudeManagedSession(session: SessionModel): boolean {
|
|
14
|
+
return session.tool === "claude" && (
|
|
15
|
+
session.detectionSource === "claude-transcript" ||
|
|
16
|
+
session.detectionSource === "claude-hook"
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function isCodexManagedSession(session: SessionModel): boolean {
|
|
21
|
+
return session.tool === "codex" && (
|
|
22
|
+
session.detectionSource === "codex-transcript" ||
|
|
23
|
+
session.detectionSource === "codex-hook"
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function clearStaleClaudeTmuxBindings(store: DaemonStore, livePaneIds: ReadonlySet<string>): void {
|
|
28
|
+
const current = store.getSessions();
|
|
29
|
+
for (const session of current) {
|
|
30
|
+
const paneId = session.legacyPaneId ?? session.tmux?.pane;
|
|
31
|
+
if (!isClaudeManagedSession(session) || !paneId) continue;
|
|
32
|
+
if (!livePaneIds.has(paneId)) {
|
|
33
|
+
store.clearTmuxBindingForPane(paneId);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function clearStaleCodexTmuxBindings(store: DaemonStore, livePaneIds: ReadonlySet<string>): void {
|
|
39
|
+
const current = store.getSessions();
|
|
40
|
+
for (const session of current) {
|
|
41
|
+
const paneId = session.legacyPaneId ?? session.tmux?.pane;
|
|
42
|
+
if (!isCodexManagedSession(session) || !paneId) continue;
|
|
43
|
+
if (!livePaneIds.has(paneId)) {
|
|
44
|
+
store.clearTmuxBindingForPane(paneId);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Compute stableForMs for a managed session (Claude/OpenCode) being enriched
|
|
51
|
+
* with tmux context. The pane classifier's stableForMs tracks how long the
|
|
52
|
+
* *pane* has been in the same heuristic state, which is unrelated to when the
|
|
53
|
+
* managed session's state last changed (that's driven by hooks/SDK).
|
|
54
|
+
* Using the pane value would make the timer jump to minutes the moment tmux
|
|
55
|
+
* polls, even if the session state just changed moments ago.
|
|
56
|
+
*/
|
|
57
|
+
export function managedStableForMs(session: SessionModel): number {
|
|
58
|
+
const since = session.stateChangedAt ?? session.lastActivity;
|
|
59
|
+
if (!since) return 0;
|
|
60
|
+
return Math.max(0, Date.now() - since);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function detectClaudePermissionPrompt(sample: PaneSample | null): { state: SessionModel["state"]; hasAttention: boolean; summary: string } | null {
|
|
64
|
+
if (!sample) return null;
|
|
65
|
+
const text = sample.lines.join("\n");
|
|
66
|
+
if (!/Do you want to proceed\?/i.test(text)) return null;
|
|
67
|
+
return {
|
|
68
|
+
state: "waiting-input",
|
|
69
|
+
hasAttention: true,
|
|
70
|
+
summary: "Do you want to proceed?",
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function isClaudePaneCandidate(
|
|
75
|
+
processTree: Array<{ command: string; comm: string }>,
|
|
76
|
+
currentCommand: string,
|
|
77
|
+
): boolean {
|
|
78
|
+
const combined = `${currentCommand} ${processTree.map((p) => `${p.comm} ${p.command}`).join(" ")}`;
|
|
79
|
+
// Match 'claude' binary but not 'claude-code' or other unrelated uses
|
|
80
|
+
return /(?:^|\s|\/)(claude)(?:\s|$)/i.test(combined);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function isCodexPaneCandidate(
|
|
84
|
+
processTree: Array<{ command: string; comm: string }>,
|
|
85
|
+
currentCommand: string,
|
|
86
|
+
): boolean {
|
|
87
|
+
const combined = `${currentCommand} ${processTree.map((p) => `${p.comm} ${p.command}`).join(" ")}`;
|
|
88
|
+
return /(?:^|\s|\/)(codex)(?:\s|$)/i.test(combined);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function detectCodexPermissionPrompt(sample: PaneSample | null): { state: SessionModel["state"]; hasAttention: boolean; summary: string } | null {
|
|
92
|
+
if (!sample) return null;
|
|
93
|
+
const text = sample.lines.join("\n");
|
|
94
|
+
|
|
95
|
+
// Codex permission prompts for tool execution approval
|
|
96
|
+
const permMatch = text.match(/approve(?:d)?\s+(?:codex\s+)?to\s+run\s+(\S+)/i)
|
|
97
|
+
?? text.match(/Allow\s+(?:this\s+)?(?:command|action|tool)\??/i)
|
|
98
|
+
?? text.match(/\[Y\]es\/\[N\]o/i);
|
|
99
|
+
if (permMatch) {
|
|
100
|
+
return {
|
|
101
|
+
state: "waiting-input",
|
|
102
|
+
hasAttention: true,
|
|
103
|
+
summary: "Permission request",
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Codex agent asking a question (bullet-point lines ending in ?)
|
|
108
|
+
const questionLines = sample.lines.filter((line) => /^\s*•\s.*\?\s*$/.test(line));
|
|
109
|
+
if (questionLines.length > 0) {
|
|
110
|
+
const last = questionLines[questionLines.length - 1]!.replace(/^\s*•\s*/, "").trim();
|
|
111
|
+
return {
|
|
112
|
+
state: "waiting-input",
|
|
113
|
+
hasAttention: true,
|
|
114
|
+
summary: last.slice(0, 120) || "Waiting for input",
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const nonEmpty = sample.lines.filter((l) => l.trim().length > 0);
|
|
119
|
+
const lastNonEmpty = nonEmpty[nonEmpty.length - 1];
|
|
120
|
+
|
|
121
|
+
// Prompt cursor, optionally with a prefilled confirmation question.
|
|
122
|
+
const promptMatch = lastNonEmpty?.match(/^\s*›\s*(.*)$/);
|
|
123
|
+
if (promptMatch) {
|
|
124
|
+
const promptText = (promptMatch[1] ?? "").trim();
|
|
125
|
+
if (promptText.length > 0 && /\?\s*$/.test(promptText)) {
|
|
126
|
+
return {
|
|
127
|
+
state: "waiting-input",
|
|
128
|
+
hasAttention: true,
|
|
129
|
+
summary: promptText.slice(0, 120),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function clearStaleOpenCodeTmuxBindings(store: DaemonStore, livePaneIds: ReadonlySet<string>): void {
|
|
138
|
+
const current = store.getSessions();
|
|
139
|
+
for (const session of current) {
|
|
140
|
+
const paneId = session.legacyPaneId ?? session.tmux?.pane;
|
|
141
|
+
if (!isOpenCodeManagedSession(session) || !paneId) {
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (!livePaneIds.has(paneId)) {
|
|
146
|
+
store.clearTmuxBindingForPane(paneId);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function isOpenCodePaneCandidate(
|
|
152
|
+
pane: TmuxPaneRecord,
|
|
153
|
+
processTree: Array<{ command: string }>,
|
|
154
|
+
classifiedTool?: string,
|
|
155
|
+
): boolean {
|
|
156
|
+
return Boolean(
|
|
157
|
+
pane.opencodeSession ||
|
|
158
|
+
classifiedTool === "opencode" ||
|
|
159
|
+
processTree.some((process) => parseOpencodeUrl(process.command) !== undefined) ||
|
|
160
|
+
isAttachClientPane(processTree),
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function isAttachClientPane(processTree: Array<{ command: string }>): boolean {
|
|
165
|
+
return processTree.some(
|
|
166
|
+
(process) =>
|
|
167
|
+
/(?:^|\s)(?:opencode|node\s+.*\/opencode|bun\s+.*\/opencode)\s+attach\b/i.test(process.command) ||
|
|
168
|
+
/\bbun\s+run\s+src\/oc\/index\.ts\b/i.test(process.command) ||
|
|
169
|
+
/(?:^|\s)(?:oc|[^\s]*\/oc)(?:\s|$)/i.test(process.command),
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function shouldHideOpenCodePane(
|
|
174
|
+
opencode: OpenCodeConfig,
|
|
175
|
+
pane: TmuxPaneRecord,
|
|
176
|
+
processTree: Array<{ command: string }>,
|
|
177
|
+
classifiedTool?: string,
|
|
178
|
+
): boolean {
|
|
179
|
+
if (opencode.mode !== "sse") {
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return isOpenCodePaneCandidate(pane, processTree, classifiedTool);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function looksLikeAgent(processTree: Array<{ command: string; comm: string }>, currentCommand: string): boolean {
|
|
187
|
+
const combined = `${currentCommand} ${processTree.map((p) => `${p.comm} ${p.command}`).join(" ")}`;
|
|
188
|
+
return /(opencode|codex|copilotcli|copilot|claude)/i.test(combined);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Converts a classified pane record into a SessionModel.
|
|
193
|
+
* Panes that are already covered by SDK collectors (opencode/claude) will
|
|
194
|
+
* be marked with legacyPaneId so those collectors can take precedence.
|
|
195
|
+
*/
|
|
196
|
+
export function paneModelToSession(pane: ReturnType<typeof classifyPane>["pane"]): SessionModel {
|
|
197
|
+
const tool = pane.tool === "opencode" ? "opencode"
|
|
198
|
+
: pane.tool === "claude" ? "claude"
|
|
199
|
+
: pane.tool === "codex" ? "codex"
|
|
200
|
+
: pane.tool === "copilotcli" ? "copilotcli"
|
|
201
|
+
: pane.tool === "shell" ? "shell"
|
|
202
|
+
: "unknown";
|
|
203
|
+
|
|
204
|
+
const state = pane.state === "working" ? "working"
|
|
205
|
+
: pane.state === "idle" ? "idle"
|
|
206
|
+
: pane.state === "waiting-input" ? "waiting-input"
|
|
207
|
+
: pane.state === "shell" ? "shell"
|
|
208
|
+
: "unknown";
|
|
209
|
+
const claudePrompt = tool === "claude" ? detectClaudePermissionPrompt(pane.sample) : null;
|
|
210
|
+
const codexPrompt = tool === "codex" ? detectCodexPermissionPrompt(pane.sample) : null;
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
id: `tmux:${pane.paneId}`,
|
|
214
|
+
source: "tmux",
|
|
215
|
+
tool,
|
|
216
|
+
state: codexPrompt?.state ?? claudePrompt?.state ?? state,
|
|
217
|
+
project: pane.project,
|
|
218
|
+
tmux: {
|
|
219
|
+
session: pane.sessionName,
|
|
220
|
+
window: String(pane.windowIndex),
|
|
221
|
+
pane: pane.paneId,
|
|
222
|
+
},
|
|
223
|
+
summary: codexPrompt?.summary ?? claudePrompt?.summary ?? pane.summary,
|
|
224
|
+
lastActivity: pane.lastChangedAt,
|
|
225
|
+
detectionSource: "fallback-detection",
|
|
226
|
+
legacyPaneId: pane.paneId,
|
|
227
|
+
// Extended fields
|
|
228
|
+
processTree: pane.processTree,
|
|
229
|
+
processChain: pane.processChain,
|
|
230
|
+
sample: pane.sample,
|
|
231
|
+
toolReasons: pane.toolReasons,
|
|
232
|
+
stateReasons: pane.stateReasons,
|
|
233
|
+
stableForMs: pane.stableForMs,
|
|
234
|
+
hasAttention: codexPrompt?.hasAttention ?? claudePrompt?.hasAttention ?? pane.hasAttention,
|
|
235
|
+
currentCommand: pane.currentCommand,
|
|
236
|
+
currentPath: pane.currentPath,
|
|
237
|
+
panePid: pane.panePid,
|
|
238
|
+
tty: pane.tty,
|
|
239
|
+
paneTitle: pane.title,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { TmuxCollector } from "./collector";
|
|
2
|
+
export { clearStaleClaudeTmuxBindings, clearStaleCodexTmuxBindings, clearStaleOpenCodeTmuxBindings, isAttachClientPane, shouldHideOpenCodePane, looksLikeAgent } from "./enrichment";
|
|
3
|
+
export { collectTmuxState, parseSessions, parsePanes, capturePane } from "./parse";
|
|
4
|
+
export type { TmuxCollectionResult } from "./parse";
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import type { PaneSample, SessionInfo, TmuxPaneRecord } from "../../../shared/types";
|
|
2
|
+
import { compactWhitespace, maybeRunCommand, parseIntSafe, pickMeaningfulTail, stripAnsiAndUi } from "../../../shared/utils";
|
|
3
|
+
|
|
4
|
+
export const PANE_FORMAT = [
|
|
5
|
+
"#{session_name}",
|
|
6
|
+
"#{window_index}",
|
|
7
|
+
"#{window_name}",
|
|
8
|
+
"#{pane_index}",
|
|
9
|
+
"#{pane_id}",
|
|
10
|
+
"#{pane_pid}",
|
|
11
|
+
"#{pane_current_command}",
|
|
12
|
+
"#{pane_current_path}",
|
|
13
|
+
"#{pane_active}",
|
|
14
|
+
"#{pane_tty}",
|
|
15
|
+
"#{pane_title}",
|
|
16
|
+
"#{pane_width}",
|
|
17
|
+
"#{pane_height}",
|
|
18
|
+
"#{@opencode-session}",
|
|
19
|
+
"#{@claude-session}",
|
|
20
|
+
"#{@codex-session}",
|
|
21
|
+
].join("\t");
|
|
22
|
+
|
|
23
|
+
export const SESSION_FORMAT = ["#{session_name}", "#{session_attached}", "#{session_windows}"].join("\t");
|
|
24
|
+
|
|
25
|
+
export interface TmuxCollectionResult {
|
|
26
|
+
sessions: SessionInfo[];
|
|
27
|
+
panes: TmuxPaneRecord[];
|
|
28
|
+
errors: string[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function collectTmuxState(timeoutMs?: number): TmuxCollectionResult {
|
|
32
|
+
const sessionResult = maybeRunCommand(["tmux", "list-sessions", "-F", SESSION_FORMAT], timeoutMs);
|
|
33
|
+
if (!sessionResult.ok) {
|
|
34
|
+
if (
|
|
35
|
+
sessionResult.error.includes("failed to connect to server") ||
|
|
36
|
+
sessionResult.error.includes("no server running")
|
|
37
|
+
) {
|
|
38
|
+
return { sessions: [], panes: [], errors: [] };
|
|
39
|
+
}
|
|
40
|
+
return { sessions: [], panes: [], errors: [sessionResult.error] };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const paneResult = maybeRunCommand(["tmux", "list-panes", "-a", "-F", PANE_FORMAT], timeoutMs);
|
|
44
|
+
const sessions = parseSessions(sessionResult.output);
|
|
45
|
+
|
|
46
|
+
if (!paneResult.ok) {
|
|
47
|
+
return { sessions, panes: [], errors: [paneResult.error] };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return { sessions, panes: parsePanes(paneResult.output), errors: [] };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function capturePane(paneId: string, lines: number, timeoutMs?: number): PaneSample | null {
|
|
54
|
+
const result = maybeRunCommand(["tmux", "capture-pane", "-p", "-t", paneId, "-S", `-${lines}`], timeoutMs);
|
|
55
|
+
if (!result.ok) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const allLines = result.output.split(/\r?\n/).map((line: string) => line.replace(/\t/g, " "));
|
|
60
|
+
const cleanLines = allLines.filter(
|
|
61
|
+
(line: string, index: number, arr: string[]) => !(index === arr.length - 1 && line === ""),
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
paneId,
|
|
66
|
+
lines: cleanLines,
|
|
67
|
+
preview: pickMeaningfulTail(cleanLines, compactWhitespace(stripAnsiAndUi(cleanLines.slice(-3).join(" ")))),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function parseSessions(output: string): SessionInfo[] {
|
|
72
|
+
return output
|
|
73
|
+
.split(/\r?\n/)
|
|
74
|
+
.filter(Boolean)
|
|
75
|
+
.map((line) => {
|
|
76
|
+
const [name, attached, windows] = line.split("\t");
|
|
77
|
+
return {
|
|
78
|
+
name: name ?? "",
|
|
79
|
+
attached: attached === "1",
|
|
80
|
+
windows: parseIntSafe(windows ?? ""),
|
|
81
|
+
};
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function parsePanes(output: string): TmuxPaneRecord[] {
|
|
86
|
+
return output
|
|
87
|
+
.split(/\r?\n/)
|
|
88
|
+
.filter(Boolean)
|
|
89
|
+
.map((line) => {
|
|
90
|
+
const [
|
|
91
|
+
sessionName,
|
|
92
|
+
windowIndex,
|
|
93
|
+
windowName,
|
|
94
|
+
paneIndex,
|
|
95
|
+
paneId,
|
|
96
|
+
panePid,
|
|
97
|
+
currentCommand,
|
|
98
|
+
currentPath,
|
|
99
|
+
active,
|
|
100
|
+
tty,
|
|
101
|
+
title,
|
|
102
|
+
width,
|
|
103
|
+
height,
|
|
104
|
+
opencodeSession,
|
|
105
|
+
claudeSession,
|
|
106
|
+
codexSession,
|
|
107
|
+
] = line.split("\t");
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
sessionName: sessionName ?? "",
|
|
111
|
+
windowIndex: parseIntSafe(windowIndex ?? ""),
|
|
112
|
+
windowName: windowName ?? "",
|
|
113
|
+
paneIndex: parseIntSafe(paneIndex ?? ""),
|
|
114
|
+
paneId: paneId ?? "",
|
|
115
|
+
panePid: parseIntSafe(panePid ?? ""),
|
|
116
|
+
currentCommand: currentCommand ?? "",
|
|
117
|
+
currentPath: currentPath ?? "",
|
|
118
|
+
active: active === "1",
|
|
119
|
+
tty: tty ?? "",
|
|
120
|
+
title: title ?? "",
|
|
121
|
+
width: parseIntSafe(width ?? ""),
|
|
122
|
+
height: parseIntSafe(height ?? ""),
|
|
123
|
+
opencodeSession: opencodeSession || undefined,
|
|
124
|
+
claudeSession: claudeSession || undefined,
|
|
125
|
+
codexSession: codexSession || undefined,
|
|
126
|
+
} satisfies TmuxPaneRecord;
|
|
127
|
+
});
|
|
128
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import type { ScrylogConfig } from "../../shared/config";
|
|
2
|
+
import type { Logger } from "../../shared/logger";
|
|
3
|
+
import type { UsageState, UsageProviderEntry } from "../../usage/types";
|
|
4
|
+
import { ClaudeSourcePlanner } from "../../usage/planner";
|
|
5
|
+
import { ClaudeUsageProvider } from "../../usage/providers/claude";
|
|
6
|
+
import { CodexUsageProvider } from "../../usage/providers/codex";
|
|
7
|
+
import { OpenCodeGoUsageProvider } from "../../usage/providers/opencode-go";
|
|
8
|
+
import { ClaudeAuthResolver } from "../../usage/resolvers/claude-auth";
|
|
9
|
+
import { ClaudeWebResolver } from "../../usage/resolvers/claude-web";
|
|
10
|
+
import { ClaudeCliResolver } from "../../usage/resolvers/claude-cli";
|
|
11
|
+
import { CodexAuthResolver } from "../../usage/resolvers/codex-auth";
|
|
12
|
+
import { OpenCodeGoWebResolver } from "../../usage/resolvers/opencode-go-web";
|
|
13
|
+
import type { DaemonStore } from "../store";
|
|
14
|
+
import type { Collector } from "../scheduler";
|
|
15
|
+
|
|
16
|
+
export class UsageCollector implements Collector {
|
|
17
|
+
name = "usage";
|
|
18
|
+
private timer?: ReturnType<typeof setInterval>;
|
|
19
|
+
private store?: DaemonStore;
|
|
20
|
+
private config?: ScrylogConfig;
|
|
21
|
+
private logger?: Logger;
|
|
22
|
+
private polling = false;
|
|
23
|
+
private skippedPolls = 0;
|
|
24
|
+
private errorCount = 0;
|
|
25
|
+
|
|
26
|
+
async start(store: DaemonStore, config: ScrylogConfig, logger: Logger): Promise<void> {
|
|
27
|
+
this.store = store;
|
|
28
|
+
this.config = config;
|
|
29
|
+
this.logger = logger;
|
|
30
|
+
|
|
31
|
+
logger.info("usage", "Collector started", { event: "collector.started", collector: this.name, intervalMs: config.usage.pollIntervalMs, providers: config.usage.providers });
|
|
32
|
+
|
|
33
|
+
await this.safePoll();
|
|
34
|
+
|
|
35
|
+
this.timer = setInterval(() => void this.safePoll(), config.usage.pollIntervalMs);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async stop(): Promise<void> {
|
|
39
|
+
if (this.timer) {
|
|
40
|
+
clearInterval(this.timer);
|
|
41
|
+
this.timer = undefined;
|
|
42
|
+
}
|
|
43
|
+
this.logger?.info("usage", "Collector stopped", { event: "collector.stopped", collector: this.name });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
private async safePoll(): Promise<void> {
|
|
47
|
+
const store = this.store!;
|
|
48
|
+
if (this.polling) {
|
|
49
|
+
this.skippedPolls += 1;
|
|
50
|
+
store.setCollectorStatus(this.name, { skippedPolls: this.skippedPolls });
|
|
51
|
+
this.logger?.debug("usage", "Skipping overlapping poll", { event: "collector.poll_skipped", collector: this.name, skippedPolls: this.skippedPolls });
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
this.polling = true;
|
|
56
|
+
const startedAt = Date.now();
|
|
57
|
+
try {
|
|
58
|
+
await this.poll();
|
|
59
|
+
store.setCollectorStatus(this.name, { lastDurationMs: Date.now() - startedAt, skippedPolls: this.skippedPolls, errorCount: this.errorCount });
|
|
60
|
+
} finally {
|
|
61
|
+
this.polling = false;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private async poll(): Promise<void> {
|
|
66
|
+
const store = this.store!;
|
|
67
|
+
const config = this.config!;
|
|
68
|
+
const logger = this.logger!;
|
|
69
|
+
|
|
70
|
+
const providers: UsageProviderEntry[] = [];
|
|
71
|
+
const attemptedAt = Date.now();
|
|
72
|
+
|
|
73
|
+
for (const providerName of config.usage.providers) {
|
|
74
|
+
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
|
+
try {
|
|
96
|
+
const result = await withAbort((signal) => claudeProvider.fetch({ signal }), config.daemon.networkTimeoutMs, "Claude usage fetch timed out");
|
|
97
|
+
providers.push(result.entry);
|
|
98
|
+
} catch (err) {
|
|
99
|
+
this.errorCount += 1;
|
|
100
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
101
|
+
logger.warn("usage", "Claude provider unexpected error", { event: "provider.fetch_failed", provider: "claude", errorMessage: message });
|
|
102
|
+
providers.push({
|
|
103
|
+
provider: "claude",
|
|
104
|
+
accountId: null,
|
|
105
|
+
available: true,
|
|
106
|
+
status: "error",
|
|
107
|
+
errors: [message],
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
} 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
|
+
try {
|
|
121
|
+
const result = await withAbort((signal) => provider.fetch({ signal }), config.daemon.networkTimeoutMs, "Codex usage fetch timed out");
|
|
122
|
+
providers.push(result.entry);
|
|
123
|
+
} catch (err) {
|
|
124
|
+
this.errorCount += 1;
|
|
125
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
126
|
+
logger.warn("usage", "Codex provider unexpected error", { event: "provider.fetch_failed", provider: "codex", errorMessage: message });
|
|
127
|
+
providers.push({
|
|
128
|
+
provider: "codex",
|
|
129
|
+
accountId: null,
|
|
130
|
+
available: true,
|
|
131
|
+
status: "error",
|
|
132
|
+
errors: [message],
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
} 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
|
+
try {
|
|
146
|
+
const result = await withAbort((signal) => provider.fetch({ signal }), config.daemon.networkTimeoutMs, "OpenCode Go usage fetch timed out");
|
|
147
|
+
providers.push(result.entry);
|
|
148
|
+
} catch (err) {
|
|
149
|
+
this.errorCount += 1;
|
|
150
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
151
|
+
logger.warn("usage", "OpenCode Go provider unexpected error", { event: "provider.fetch_failed", provider: "opencode-go", errorMessage: message });
|
|
152
|
+
providers.push({
|
|
153
|
+
provider: "opencode-go",
|
|
154
|
+
accountId: null,
|
|
155
|
+
available: true,
|
|
156
|
+
status: "error",
|
|
157
|
+
errors: [message],
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const state: UsageState = {
|
|
164
|
+
providers,
|
|
165
|
+
lastRefreshedAt: attemptedAt,
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
store.setUsage(state);
|
|
169
|
+
store.setCollectorStatus(this.name, { running: true, lastRunAt: attemptedAt, skippedPolls: this.skippedPolls, errorCount: this.errorCount });
|
|
170
|
+
logger.debug("usage", "Poll complete", { event: "collector.poll_completed", collector: this.name, providerCount: providers.length });
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function withAbort<T>(run: (signal: AbortSignal) => Promise<T>, timeoutMs: number, message: string): Promise<T> {
|
|
175
|
+
const controller = new AbortController();
|
|
176
|
+
const promise = run(controller.signal);
|
|
177
|
+
return new Promise((resolve, reject) => {
|
|
178
|
+
const timer = setTimeout(() => {
|
|
179
|
+
controller.abort();
|
|
180
|
+
reject(new Error(message));
|
|
181
|
+
}, timeoutMs);
|
|
182
|
+
promise.then(
|
|
183
|
+
(value) => {
|
|
184
|
+
clearTimeout(timer);
|
|
185
|
+
resolve(value);
|
|
186
|
+
},
|
|
187
|
+
(error) => {
|
|
188
|
+
clearTimeout(timer);
|
|
189
|
+
reject(error);
|
|
190
|
+
},
|
|
191
|
+
);
|
|
192
|
+
});
|
|
193
|
+
}
|