@scrylog/cli 0.2.0 → 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 +8 -0
- package/src/api/routes/webhooks/opencode.ts +1 -0
- 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 +85 -3
- package/src/cli/index.ts +18 -0
- package/src/cli/program.ts +175 -0
- package/src/daemon/collectors/claude/collector.ts +1 -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-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 +44 -2
- 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/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 +407 -69
- package/src/usage/resolvers/claude-auth.ts +140 -40
- package/src/usage/resolvers/claude-desktop-auth.ts +15 -5
- 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 -217
|
@@ -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
|
}
|
package/src/daemon/store.ts
CHANGED
|
@@ -124,6 +124,24 @@ export class DaemonStore extends EventEmitter {
|
|
|
124
124
|
// enrichment fields. This prevents the opencode SDK/DB/cluade collectors from
|
|
125
125
|
// wiping out tmux enrichment on every poll/SSE cycle.
|
|
126
126
|
let session = incoming;
|
|
127
|
+
if (
|
|
128
|
+
existing?.source === "herdr"
|
|
129
|
+
&& existing.herdr
|
|
130
|
+
&& incoming.detectionSource !== "herdr"
|
|
131
|
+
&& incoming.source !== "tmux"
|
|
132
|
+
&& !incoming.tmux
|
|
133
|
+
&& !incoming.legacyPaneId
|
|
134
|
+
) {
|
|
135
|
+
session = {
|
|
136
|
+
...incoming,
|
|
137
|
+
source: "herdr",
|
|
138
|
+
herdr: existing.herdr,
|
|
139
|
+
project: incoming.project ?? existing.project,
|
|
140
|
+
currentPath: incoming.currentPath ?? existing.currentPath,
|
|
141
|
+
currentCommand: incoming.currentCommand ?? existing.currentCommand,
|
|
142
|
+
paneTitle: incoming.paneTitle ?? existing.paneTitle,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
127
145
|
if (existing?.legacyPaneId && !incoming.legacyPaneId) {
|
|
128
146
|
const stableForMs = existing.state !== incoming.state ? 0 : existing.stableForMs;
|
|
129
147
|
session = {
|
|
@@ -419,6 +437,29 @@ export class DaemonStore extends EventEmitter {
|
|
|
419
437
|
};
|
|
420
438
|
}
|
|
421
439
|
|
|
440
|
+
/** Remove herdr location while retaining native collector session data. */
|
|
441
|
+
clearHerdrBinding(sessionId: string): void {
|
|
442
|
+
const existing = this.state.sessions.find((session) => session.id === sessionId);
|
|
443
|
+
if (!existing || existing.source !== "herdr" || !existing.herdr || existing.detectionSource === "herdr") {
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const session: SessionModel = {
|
|
448
|
+
...existing,
|
|
449
|
+
source: "standalone",
|
|
450
|
+
herdr: undefined,
|
|
451
|
+
currentCommand: undefined,
|
|
452
|
+
paneTitle: undefined,
|
|
453
|
+
};
|
|
454
|
+
this.state = {
|
|
455
|
+
...this.state,
|
|
456
|
+
sessions: this.state.sessions.map((item) => item.id === sessionId ? session : item),
|
|
457
|
+
refreshedAt: Date.now(),
|
|
458
|
+
};
|
|
459
|
+
this.emit("store:event", { type: "session:updated", session } satisfies StoreEvent);
|
|
460
|
+
this.emit("store:event", { type: "state:refreshed", timestamp: this.state.refreshedAt } satisfies StoreEvent);
|
|
461
|
+
}
|
|
462
|
+
|
|
422
463
|
/** Update usage state and emit event */
|
|
423
464
|
setUsage(usage: import("../usage/types").UsageState): void {
|
|
424
465
|
this.state = { ...this.state, usage };
|
|
@@ -518,9 +559,10 @@ export class DaemonStore extends EventEmitter {
|
|
|
518
559
|
const maxSessions = retention.maxSessions;
|
|
519
560
|
if (maxSessions > 0) {
|
|
520
561
|
const survivors = this.state.sessions.filter((session) => !removeIds.has(session.id));
|
|
521
|
-
const
|
|
562
|
+
const counted = survivors.filter((session) => session.source === "standalone" && session.tool !== "unknown");
|
|
563
|
+
const overflow = counted.length - maxSessions;
|
|
522
564
|
if (overflow > 0) {
|
|
523
|
-
const candidates =
|
|
565
|
+
const candidates = counted
|
|
524
566
|
.filter((session) => !active(session))
|
|
525
567
|
.sort((a, b) => a.lastActivity - b.lastActivity);
|
|
526
568
|
for (const session of candidates.slice(0, overflow)) {
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
const message = [
|
|
2
|
+
"scrylog installed or updated.",
|
|
3
|
+
"If scrylogd is already running, activate this version with:",
|
|
4
|
+
" scrylog daemon restart",
|
|
5
|
+
"For a foreground or unsupported service, stop it and run:",
|
|
6
|
+
" scrylogd",
|
|
7
|
+
"Hooks use the installed binaries and do not need reinstallation.",
|
|
8
|
+
"If integrations misbehave, run: scrylog setup",
|
|
9
|
+
].join("\n");
|
|
10
|
+
|
|
11
|
+
process.stdout.write(`${message}\n`);
|
package/src/shared/config.ts
CHANGED
|
@@ -91,8 +91,20 @@ export interface TuiConfig {
|
|
|
91
91
|
standaloneMaxAgeHours: number;
|
|
92
92
|
/** Debounce window in ms for coalescing push-event bursts into a single refresh (default: 120, 0 = disabled) */
|
|
93
93
|
pushDebounceMs: number;
|
|
94
|
+
/**
|
|
95
|
+
* Provider icon rendering mode (default: blocks):
|
|
96
|
+
* - "blocks": terminal pixel art, works everywhere including tmux
|
|
97
|
+
* - "graphics": full artwork negotiated automatically (kitty → sixel → blocks)
|
|
98
|
+
* - "kitty" / "sixel": force a specific native graphics protocol, ignoring detection
|
|
99
|
+
* - "text": colored provider initials
|
|
100
|
+
*/
|
|
101
|
+
providerIcons: "blocks" | "graphics" | "kitty" | "sixel" | "text";
|
|
94
102
|
}
|
|
95
103
|
|
|
104
|
+
type ProviderIconsMode = TuiConfig["providerIcons"];
|
|
105
|
+
|
|
106
|
+
const PROVIDER_ICON_MODES: readonly ProviderIconsMode[] = ["blocks", "graphics", "kitty", "sixel", "text"];
|
|
107
|
+
|
|
96
108
|
export interface McpConfig {
|
|
97
109
|
/** Whether the local stdio MCP binary should be allowed (default: true) */
|
|
98
110
|
stdioEnabled: boolean;
|
|
@@ -142,6 +154,14 @@ export interface UsageOpenCodeGoConfig {
|
|
|
142
154
|
serverFnId: string;
|
|
143
155
|
}
|
|
144
156
|
|
|
157
|
+
export interface UsageAuthConfig {
|
|
158
|
+
/**
|
|
159
|
+
* Whether background usage polls may read foreign Keychain secrets (macOS).
|
|
160
|
+
* `false` keeps the daemon prompt-free but disables Keychain-backed credential sources.
|
|
161
|
+
*/
|
|
162
|
+
allowKeychainPrompts: boolean;
|
|
163
|
+
}
|
|
164
|
+
|
|
145
165
|
export interface UsageConfig {
|
|
146
166
|
/** Whether usage collection is enabled (default: true) */
|
|
147
167
|
enabled: boolean;
|
|
@@ -152,6 +172,7 @@ export interface UsageConfig {
|
|
|
152
172
|
claude: UsageClaudeConfig;
|
|
153
173
|
codex: UsageCodexConfig;
|
|
154
174
|
opencodeGo: UsageOpenCodeGoConfig;
|
|
175
|
+
auth: UsageAuthConfig;
|
|
155
176
|
}
|
|
156
177
|
|
|
157
178
|
export interface RetentionConfig {
|
|
@@ -233,6 +254,7 @@ export const CONFIG_DEFAULTS: ScrylogConfig = {
|
|
|
233
254
|
usePushUpdates: true,
|
|
234
255
|
standaloneMaxAgeHours: 6,
|
|
235
256
|
pushDebounceMs: 120,
|
|
257
|
+
providerIcons: "blocks",
|
|
236
258
|
},
|
|
237
259
|
mcp: {
|
|
238
260
|
stdioEnabled: true,
|
|
@@ -254,6 +276,9 @@ export const CONFIG_DEFAULTS: ScrylogConfig = {
|
|
|
254
276
|
opencodeGo: {
|
|
255
277
|
serverFnId: "def39973159c7f0483d8793a822b8dbb10d067e12c65455fcb4608459ba0234f",
|
|
256
278
|
},
|
|
279
|
+
auth: {
|
|
280
|
+
allowKeychainPrompts: true,
|
|
281
|
+
},
|
|
257
282
|
},
|
|
258
283
|
retention: {
|
|
259
284
|
maxSessions: 500,
|
|
@@ -456,10 +481,18 @@ function normalizeOperationalConfig(config: ScrylogConfig): ScrylogConfig {
|
|
|
456
481
|
refreshMs: clampNumber(config.tui.refreshMs, 500, 300_000, CONFIG_DEFAULTS.tui.refreshMs),
|
|
457
482
|
standaloneMaxAgeHours: clampNumber(config.tui.standaloneMaxAgeHours, 0, 8_760, CONFIG_DEFAULTS.tui.standaloneMaxAgeHours),
|
|
458
483
|
pushDebounceMs: clampNumber(config.tui.pushDebounceMs, 0, 2_000, CONFIG_DEFAULTS.tui.pushDebounceMs),
|
|
484
|
+
providerIcons: PROVIDER_ICON_MODES.includes(config.tui.providerIcons)
|
|
485
|
+
? config.tui.providerIcons
|
|
486
|
+
: CONFIG_DEFAULTS.tui.providerIcons,
|
|
459
487
|
},
|
|
460
488
|
usage: {
|
|
461
489
|
...config.usage,
|
|
462
490
|
pollIntervalMs: clampNumber(config.usage.pollIntervalMs, 60_000, 3_600_000, CONFIG_DEFAULTS.usage.pollIntervalMs),
|
|
491
|
+
auth: {
|
|
492
|
+
allowKeychainPrompts: typeof config.usage.auth?.allowKeychainPrompts === "boolean"
|
|
493
|
+
? config.usage.auth.allowKeychainPrompts
|
|
494
|
+
: CONFIG_DEFAULTS.usage.auth.allowKeychainPrompts,
|
|
495
|
+
},
|
|
463
496
|
},
|
|
464
497
|
retention: {
|
|
465
498
|
...config.retention,
|
|
@@ -15,6 +15,18 @@ export interface SessionListTmuxInfo {
|
|
|
15
15
|
session: string;
|
|
16
16
|
window: string;
|
|
17
17
|
pane: string;
|
|
18
|
+
windowName?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface SessionListHerdrInfo {
|
|
22
|
+
paneId: string;
|
|
23
|
+
terminalId?: string;
|
|
24
|
+
workspaceId?: string;
|
|
25
|
+
workspace?: string;
|
|
26
|
+
tabId?: string;
|
|
27
|
+
tab?: string;
|
|
28
|
+
name?: string;
|
|
29
|
+
agent?: string;
|
|
18
30
|
}
|
|
19
31
|
|
|
20
32
|
export interface SessionListItem {
|
|
@@ -28,8 +40,11 @@ export interface SessionListItem {
|
|
|
28
40
|
hasAttention?: boolean;
|
|
29
41
|
detectionSource?: SessionModel["detectionSource"];
|
|
30
42
|
parentSessionId?: string;
|
|
43
|
+
/** Human label for the session row: pane title > window/tab name > tmux session name */
|
|
44
|
+
displayName?: string;
|
|
31
45
|
project?: SessionListProjectInfo;
|
|
32
46
|
tmux?: SessionListTmuxInfo;
|
|
47
|
+
herdr?: SessionListHerdrInfo;
|
|
33
48
|
currentPath?: string;
|
|
34
49
|
activeTool?: string;
|
|
35
50
|
childCount?: number;
|
|
@@ -54,6 +69,10 @@ export interface SessionView {
|
|
|
54
69
|
sessions: SessionTreeItem[];
|
|
55
70
|
}
|
|
56
71
|
|
|
72
|
+
export interface SessionViewOptions {
|
|
73
|
+
includeUnknown?: boolean;
|
|
74
|
+
}
|
|
75
|
+
|
|
57
76
|
function makeStateCounts(): Record<SessionState, number> {
|
|
58
77
|
return {
|
|
59
78
|
working: 0,
|
|
@@ -90,7 +109,16 @@ function isEphemeralCodexRootSession(session: SessionModel, now: number): boolea
|
|
|
90
109
|
&& now - session.lastActivity > CODEX_ROOT_EPHEMERAL_GRACE_MS;
|
|
91
110
|
}
|
|
92
111
|
|
|
93
|
-
function isCanonicallyVisible(
|
|
112
|
+
function isCanonicallyVisible(
|
|
113
|
+
session: SessionModel,
|
|
114
|
+
config: ScrylogConfig,
|
|
115
|
+
now: number,
|
|
116
|
+
options: SessionViewOptions,
|
|
117
|
+
): boolean {
|
|
118
|
+
if (session.tool === "unknown" && !options.includeUnknown) {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
|
|
94
122
|
if (session.parentSessionId) {
|
|
95
123
|
return session.state === "working" || session.state === "waiting-input";
|
|
96
124
|
}
|
|
@@ -125,6 +153,23 @@ function toProject(project?: ProjectInfo): SessionListProjectInfo | undefined {
|
|
|
125
153
|
|
|
126
154
|
const GRACE_PERIOD_MS = 1500;
|
|
127
155
|
|
|
156
|
+
function resolveDisplayName(session: SessionModel): string | undefined {
|
|
157
|
+
if (session.tmux) {
|
|
158
|
+
return session.paneTitle?.trim()
|
|
159
|
+
|| session.tmux.windowName?.trim()
|
|
160
|
+
|| session.tmux.session?.trim()
|
|
161
|
+
|| undefined;
|
|
162
|
+
}
|
|
163
|
+
if (session.herdr) {
|
|
164
|
+
return session.herdr.name?.trim()
|
|
165
|
+
|| session.herdr.tab?.trim()
|
|
166
|
+
|| session.herdr.agent?.trim()
|
|
167
|
+
|| session.herdr.paneId
|
|
168
|
+
|| undefined;
|
|
169
|
+
}
|
|
170
|
+
return undefined;
|
|
171
|
+
}
|
|
172
|
+
|
|
128
173
|
function effectiveState(session: SessionModel, activeChildCount: number, now: number): SessionState {
|
|
129
174
|
if (session.state === "idle" && !session.parentSessionId) {
|
|
130
175
|
if (activeChildCount > 0) {
|
|
@@ -160,8 +205,19 @@ function toListItem(session: SessionModel, childCount: number, activeChildCount:
|
|
|
160
205
|
hasAttention: session.hasAttention,
|
|
161
206
|
detectionSource: session.detectionSource,
|
|
162
207
|
parentSessionId: session.parentSessionId,
|
|
208
|
+
displayName: resolveDisplayName(session),
|
|
163
209
|
project: toProject(session.project),
|
|
164
|
-
tmux: session.tmux,
|
|
210
|
+
tmux: session.tmux ? { ...session.tmux } : undefined,
|
|
211
|
+
herdr: session.herdr ? {
|
|
212
|
+
paneId: session.herdr.paneId,
|
|
213
|
+
terminalId: session.herdr.terminalId,
|
|
214
|
+
workspaceId: session.herdr.workspaceId,
|
|
215
|
+
workspace: session.herdr.workspace,
|
|
216
|
+
tabId: session.herdr.tabId,
|
|
217
|
+
tab: session.herdr.tab,
|
|
218
|
+
name: session.herdr.name,
|
|
219
|
+
agent: session.herdr.agent,
|
|
220
|
+
} : undefined,
|
|
165
221
|
currentPath: session.currentPath,
|
|
166
222
|
activeTool: session.activeTool,
|
|
167
223
|
childCount,
|
|
@@ -179,7 +235,12 @@ export function flattenSessionTree(sessions: SessionTreeItem[]): SessionListItem
|
|
|
179
235
|
return flat;
|
|
180
236
|
}
|
|
181
237
|
|
|
182
|
-
export function buildSessionView(
|
|
238
|
+
export function buildSessionView(
|
|
239
|
+
sessions: SessionModel[],
|
|
240
|
+
config: ScrylogConfig,
|
|
241
|
+
now = Date.now(),
|
|
242
|
+
options: SessionViewOptions = {},
|
|
243
|
+
): SessionView {
|
|
183
244
|
const byId = new Map(sessions.map((session) => [session.id, session]));
|
|
184
245
|
const allChildren = new Map<string, SessionModel[]>();
|
|
185
246
|
const visibleChildren = new Map<string, SessionModel[]>();
|
|
@@ -191,20 +252,20 @@ export function buildSessionView(sessions: SessionModel[], config: ScrylogConfig
|
|
|
191
252
|
arr.push(session);
|
|
192
253
|
allChildren.set(session.parentSessionId, arr);
|
|
193
254
|
|
|
194
|
-
if (isCanonicallyVisible(session, config, now)) {
|
|
255
|
+
if (isCanonicallyVisible(session, config, now, options)) {
|
|
195
256
|
const visibleArr = visibleChildren.get(session.parentSessionId) ?? [];
|
|
196
257
|
visibleArr.push(session);
|
|
197
258
|
visibleChildren.set(session.parentSessionId, visibleArr);
|
|
198
259
|
|
|
199
260
|
const parent = byId.get(session.parentSessionId);
|
|
200
|
-
if (parent) {
|
|
261
|
+
if (parent && isCanonicallyVisible(parent, config, now, options)) {
|
|
201
262
|
visibleRoots.set(parent.id, parent);
|
|
202
263
|
}
|
|
203
264
|
}
|
|
204
265
|
continue;
|
|
205
266
|
}
|
|
206
267
|
|
|
207
|
-
if (isCanonicallyVisible(session, config, now)) {
|
|
268
|
+
if (isCanonicallyVisible(session, config, now, options)) {
|
|
208
269
|
visibleRoots.set(session.id, session);
|
|
209
270
|
}
|
|
210
271
|
}
|