@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
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { getInstalledVersion } from "../shared/version";
|
|
3
|
+
import {
|
|
4
|
+
runClaudeHooksInstall,
|
|
5
|
+
runCodexHooksInstall,
|
|
6
|
+
runConfigCommand,
|
|
7
|
+
runDaemonCommand,
|
|
8
|
+
runDoctor,
|
|
9
|
+
runLogsCommand,
|
|
10
|
+
runOpenCodeHooksInstall,
|
|
11
|
+
runSetupWizard,
|
|
12
|
+
runTokenCommand,
|
|
13
|
+
} from "./commands";
|
|
14
|
+
import type { GlobalCliOptions } from "./commands";
|
|
15
|
+
|
|
16
|
+
export interface CliIO {
|
|
17
|
+
writeOut(message: string): void;
|
|
18
|
+
writeErr(message: string): void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type TuiLauncher = (configPath?: string, includeUnknown?: boolean) => void | Promise<void>;
|
|
22
|
+
|
|
23
|
+
interface CliHandlers {
|
|
24
|
+
setup(options: GlobalCliOptions): void | Promise<void>;
|
|
25
|
+
doctor(options: GlobalCliOptions): void | Promise<void>;
|
|
26
|
+
daemon(action: string, options: GlobalCliOptions): void | Promise<void>;
|
|
27
|
+
config(action: string, options: GlobalCliOptions): void | Promise<void>;
|
|
28
|
+
token(target: "mcp" | "api" | "both", action: string, options: GlobalCliOptions): void | Promise<void>;
|
|
29
|
+
logs(options: GlobalCliOptions): void | Promise<void>;
|
|
30
|
+
claudeHooksInstall(options: GlobalCliOptions): void | Promise<void>;
|
|
31
|
+
codexHooksInstall(options: GlobalCliOptions): void | Promise<void>;
|
|
32
|
+
openCodeHooksInstall(options: GlobalCliOptions): void | Promise<void>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface CreateProgramOptions {
|
|
36
|
+
io?: CliIO;
|
|
37
|
+
launchTui?: TuiLauncher;
|
|
38
|
+
handlers?: Partial<CliHandlers>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface ProgramOptions {
|
|
42
|
+
config?: string;
|
|
43
|
+
includeUnknown?: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const HELP_TEXT = `scrylog
|
|
47
|
+
|
|
48
|
+
Usage:
|
|
49
|
+
scrylog [--config <path>] [--include-unknown]
|
|
50
|
+
scrylog setup [--config <path>]
|
|
51
|
+
scrylog doctor [--config <path>]
|
|
52
|
+
scrylog daemon <install|uninstall|start|stop|restart|status> [--config <path>]
|
|
53
|
+
scrylog config <show|path> [--config <path>]
|
|
54
|
+
scrylog token <mcp|api|both> <generate|rotate|clear> [--config <path>]
|
|
55
|
+
scrylog claude hooks install [--config <path>]
|
|
56
|
+
scrylog codex hooks install [--config <path>]
|
|
57
|
+
scrylog opencode hooks install [--config <path>]
|
|
58
|
+
scrylog logs [--config <path>]
|
|
59
|
+
|
|
60
|
+
Options:
|
|
61
|
+
--config <path> Load config from an explicit path
|
|
62
|
+
--include-unknown Include sessions whose tool is unidentified
|
|
63
|
+
-h, --help Show this help text
|
|
64
|
+
-V, --version Show the installed scrylog version
|
|
65
|
+
|
|
66
|
+
See also:
|
|
67
|
+
scrylogd Background daemon (collects sessions and serves API/MCP)
|
|
68
|
+
scrylog-mcp stdio MCP server that proxies daemon data to AI agents
|
|
69
|
+
scrylog-claude-hook Claude Code lifecycle hook binary
|
|
70
|
+
scrylog-codex-hook Codex CLI lifecycle hook binary
|
|
71
|
+
|
|
72
|
+
Notes:
|
|
73
|
+
Running scrylog with no subcommand launches the TUI dashboard.
|
|
74
|
+
'scrylog claude hooks install' configures Claude Code to emit lifecycle
|
|
75
|
+
hooks that scrylog uses for real-time session state tracking.
|
|
76
|
+
'scrylog codex hooks install' configures Codex CLI to emit lifecycle
|
|
77
|
+
hooks that scrylog uses for real-time session state tracking.
|
|
78
|
+
'scrylog opencode hooks install' registers the scrylog plugin in
|
|
79
|
+
OpenCode configuration for real-time session state tracking.
|
|
80
|
+
`;
|
|
81
|
+
|
|
82
|
+
export const TOKEN_USAGE = "Usage: scrylog token <mcp|api|both> <generate|rotate|clear>";
|
|
83
|
+
|
|
84
|
+
export function parseTokenArgs(args: Array<string | undefined>): { target: "mcp" | "api" | "both"; action: string } {
|
|
85
|
+
const [target, action] = args;
|
|
86
|
+
if (!target) throw new Error(`Missing token target (mcp, api, or both). ${TOKEN_USAGE}`);
|
|
87
|
+
if (!action) throw new Error(`Missing token action (generate, rotate, or clear). ${TOKEN_USAGE}`);
|
|
88
|
+
if (target !== "mcp" && target !== "api" && target !== "both") {
|
|
89
|
+
throw new Error(`Invalid token target: ${target}. Must be mcp, api, or both. ${TOKEN_USAGE}`);
|
|
90
|
+
}
|
|
91
|
+
return { target, action };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function defaultIO(): CliIO {
|
|
95
|
+
return {
|
|
96
|
+
writeOut: (message) => process.stdout.write(message),
|
|
97
|
+
writeErr: (message) => process.stderr.write(message),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function defaultLaunchTui(configPath?: string, includeUnknown = false): Promise<void> {
|
|
102
|
+
const tui = await import("../tui/index") as { launchTui: TuiLauncher };
|
|
103
|
+
await tui.launchTui(configPath, includeUnknown);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function globalOptions(command: Command): GlobalCliOptions {
|
|
107
|
+
const options = command.optsWithGlobals<ProgramOptions>();
|
|
108
|
+
return { configPath: options.config };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function createProgram(options: CreateProgramOptions = {}): Command {
|
|
112
|
+
const io = options.io ?? defaultIO();
|
|
113
|
+
const launchTui = options.launchTui ?? defaultLaunchTui;
|
|
114
|
+
const handlers: CliHandlers = {
|
|
115
|
+
setup: runSetupWizard,
|
|
116
|
+
doctor: runDoctor,
|
|
117
|
+
daemon: runDaemonCommand,
|
|
118
|
+
config: runConfigCommand,
|
|
119
|
+
token: runTokenCommand,
|
|
120
|
+
logs: runLogsCommand,
|
|
121
|
+
claudeHooksInstall: runClaudeHooksInstall,
|
|
122
|
+
codexHooksInstall: runCodexHooksInstall,
|
|
123
|
+
openCodeHooksInstall: runOpenCodeHooksInstall,
|
|
124
|
+
...options.handlers,
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const program = new Command();
|
|
128
|
+
program
|
|
129
|
+
.name("scrylog")
|
|
130
|
+
.option("--config <path>", "Load config from an explicit path")
|
|
131
|
+
.option("--include-unknown", "Include sessions whose tool is unidentified")
|
|
132
|
+
.version(`scrylog ${getInstalledVersion()}`, "-V, --version", "Show the installed scrylog version")
|
|
133
|
+
.helpOption("-h, --help", "Show this help text")
|
|
134
|
+
.configureOutput({ writeOut: io.writeOut, writeErr: io.writeErr })
|
|
135
|
+
.configureHelp({ formatHelp: () => HELP_TEXT })
|
|
136
|
+
.showHelpAfterError("(see --help for usage)")
|
|
137
|
+
.exitOverride()
|
|
138
|
+
.argument("[command]")
|
|
139
|
+
.action(async (unknownCommand?: string) => {
|
|
140
|
+
if (unknownCommand) {
|
|
141
|
+
program.error(`error: unknown command '${unknownCommand}'`, { code: "commander.unknownCommand" });
|
|
142
|
+
}
|
|
143
|
+
const rootOptions = program.opts<ProgramOptions>();
|
|
144
|
+
await launchTui(rootOptions.config ?? process.env.SCRYLOG_CONFIG, rootOptions.includeUnknown ?? false);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
const setup = program.command("setup").action(() => handlers.setup(globalOptions(setup)));
|
|
148
|
+
const doctor = program.command("doctor").action(() => handlers.doctor(globalOptions(doctor)));
|
|
149
|
+
const daemon = program
|
|
150
|
+
.command("daemon")
|
|
151
|
+
.argument("<action>", "install, uninstall, start, stop, restart, or status")
|
|
152
|
+
.action((action: string) => handlers.daemon(action, globalOptions(daemon)));
|
|
153
|
+
const config = program
|
|
154
|
+
.command("config")
|
|
155
|
+
.argument("<action>", "show or path")
|
|
156
|
+
.action((action: string) => handlers.config(action, globalOptions(config)));
|
|
157
|
+
const token = program
|
|
158
|
+
.command("token")
|
|
159
|
+
.argument("[target]", "mcp, api, or both")
|
|
160
|
+
.argument("[action]", "generate, rotate, or clear")
|
|
161
|
+
.action((target: string | undefined, action: string | undefined) => {
|
|
162
|
+
const parsed = parseTokenArgs([target, action]);
|
|
163
|
+
return handlers.token(parsed.target, parsed.action, globalOptions(token));
|
|
164
|
+
});
|
|
165
|
+
const logs = program.command("logs").action(() => handlers.logs(globalOptions(logs)));
|
|
166
|
+
|
|
167
|
+
const claudeInstall = program.command("claude").command("hooks").command("install");
|
|
168
|
+
claudeInstall.action(() => handlers.claudeHooksInstall(globalOptions(claudeInstall)));
|
|
169
|
+
const codexInstall = program.command("codex").command("hooks").command("install");
|
|
170
|
+
codexInstall.action(() => handlers.codexHooksInstall(globalOptions(codexInstall)));
|
|
171
|
+
const openCodeInstall = program.command("opencode").command("hooks").command("install");
|
|
172
|
+
openCodeInstall.action(() => handlers.openCodeHooksInstall(globalOptions(openCodeInstall)));
|
|
173
|
+
|
|
174
|
+
return program;
|
|
175
|
+
}
|
|
@@ -133,7 +133,7 @@ export class ClaudeCollector implements Collector {
|
|
|
133
133
|
// Don't remove hook-managed sessions — they have an authoritative lifecycle
|
|
134
134
|
// managed by hooks (SessionEnd). Only remove transcript-sourced sessions.
|
|
135
135
|
const existingSession = store.getSession(`claude:${transcript.sessionId}`);
|
|
136
|
-
if (existingSession?.detectionSource !== "claude-hook") {
|
|
136
|
+
if (existingSession?.detectionSource !== "claude-hook" && existingSession?.source === "standalone") {
|
|
137
137
|
store.removeSession(`claude:${transcript.sessionId}`);
|
|
138
138
|
}
|
|
139
139
|
}
|
|
@@ -5,6 +5,7 @@ import type { ScrylogConfig } from "../../../shared/config";
|
|
|
5
5
|
import type { Logger } from "../../../shared/logger";
|
|
6
6
|
import { maybeRunCommand } from "../../../shared/utils";
|
|
7
7
|
import { IS_WINDOWS } from "../../../shared/platform";
|
|
8
|
+
import { isKnownClaudeProbePath } from "../../../shared/session-exclusions";
|
|
8
9
|
|
|
9
10
|
export interface DiscoveredTranscript {
|
|
10
11
|
sessionId: string;
|
|
@@ -17,28 +18,6 @@ export function getClaudeDir(config: ScrylogConfig): string {
|
|
|
17
18
|
return config.claude.claudeDir ?? join(homedir(), ".claude");
|
|
18
19
|
}
|
|
19
20
|
|
|
20
|
-
// Internal: project slugs known to generate headless probe transcripts.
|
|
21
|
-
// Third-party tools (e.g. CodexBar's ClaudeProbe on macOS) launch `claude`
|
|
22
|
-
// periodically to run slash commands like `/usage` for their own telemetry.
|
|
23
|
-
// Each run leaves a tiny transcript that scrylog misreads as an interactive
|
|
24
|
-
// "working" session, cluttering the TUI with ghosts.
|
|
25
|
-
// Not user-configurable — extend here when new probe sources are identified.
|
|
26
|
-
// Patterns are matched case-insensitively with `*` wildcards; on tiers where
|
|
27
|
-
// these tools do not run (Linux/WSL 2, Windows native) the list is a no-op.
|
|
28
|
-
const KNOWN_PROBE_SLUG_GLOBS: readonly string[] = ["*CodexBar-ClaudeProbe*"];
|
|
29
|
-
|
|
30
|
-
function matchesGlob(value: string, pattern: string): boolean {
|
|
31
|
-
const re = new RegExp(
|
|
32
|
-
"^" + pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*") + "$",
|
|
33
|
-
"i",
|
|
34
|
-
);
|
|
35
|
-
return re.test(value);
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function isKnownProbeSlug(slug: string): boolean {
|
|
39
|
-
return KNOWN_PROBE_SLUG_GLOBS.some((pattern) => matchesGlob(slug, pattern));
|
|
40
|
-
}
|
|
41
|
-
|
|
42
21
|
/**
|
|
43
22
|
* Decodes a Claude project slug (e.g. "-Volumes-Files-Workspace-Projects-other-scrylog-feature-daemon")
|
|
44
23
|
* back to an absolute filesystem path ("/Volumes/Files/Workspace/Projects/other/scrylog-feature-daemon").
|
|
@@ -87,7 +66,7 @@ export function discoverTranscripts(claudeDir: string, hookManagedIds?: Readonly
|
|
|
87
66
|
// Skip project directories emitted by known headless probe tools
|
|
88
67
|
// (e.g. CodexBar's ClaudeProbe). Their transcripts are not interactive
|
|
89
68
|
// sessions and would otherwise show as permanent "working" ghosts.
|
|
90
|
-
if (
|
|
69
|
+
if (isKnownClaudeProbePath(projectSlug)) continue;
|
|
91
70
|
const projectPath = join(projectsDir, projectSlug);
|
|
92
71
|
try {
|
|
93
72
|
if (!statSync(projectPath).isDirectory()) continue;
|
|
@@ -122,14 +122,14 @@ function hasAnsweredRequestUserInputWork(entries: TranscriptEntry[]): boolean {
|
|
|
122
122
|
return false;
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
-
function preserveLocationMetadata(model: SessionModel, existing?: SessionModel): SessionModel {
|
|
125
|
+
export function preserveLocationMetadata(model: SessionModel, existing?: SessionModel): SessionModel {
|
|
126
126
|
if (!existing) return model;
|
|
127
|
-
|
|
128
127
|
return {
|
|
129
128
|
...model,
|
|
130
129
|
source: existing.source,
|
|
131
130
|
project: existing.project ?? model.project,
|
|
132
131
|
tmux: existing.tmux ?? model.tmux,
|
|
132
|
+
herdr: existing.herdr ?? model.herdr,
|
|
133
133
|
legacyPaneId: existing.legacyPaneId ?? model.legacyPaneId,
|
|
134
134
|
processTree: existing.processTree ?? model.processTree,
|
|
135
135
|
processChain: existing.processChain ?? model.processChain,
|
|
@@ -267,7 +267,7 @@ export class CodexCollector implements Collector {
|
|
|
267
267
|
// Don't remove hook-managed sessions — they have an authoritative lifecycle
|
|
268
268
|
// managed by hooks. Only remove transcript-sourced sessions.
|
|
269
269
|
const existingSession = store.getSession(`codex:${transcript.sessionId}`);
|
|
270
|
-
if (existingSession?.detectionSource !== "codex-hook") {
|
|
270
|
+
if (existingSession?.detectionSource !== "codex-hook" && existingSession?.source === "standalone") {
|
|
271
271
|
store.removeSession(`codex:${transcript.sessionId}`);
|
|
272
272
|
}
|
|
273
273
|
}
|
|
@@ -18,12 +18,70 @@ interface GitCommandContext {
|
|
|
18
18
|
workTree?: string;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Freshness tracking for cached ProjectInfo entries. The branch (and only the
|
|
23
|
+
* branch) goes stale when the repo's HEAD file changes; everything else
|
|
24
|
+
* (displayName, remote, worktree status) is stable per cwd. We fingerprint the
|
|
25
|
+
* resolved HEAD file — works for regular repos, worktrees, and WSL-backed
|
|
26
|
+
* Windows checkouts, and never spawns a git subprocess.
|
|
27
|
+
*/
|
|
28
|
+
interface FreshnessMeta {
|
|
29
|
+
headPath?: string;
|
|
30
|
+
headMtimeMs?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const freshnessByProject = new WeakMap<ProjectInfo, FreshnessMeta>();
|
|
34
|
+
|
|
35
|
+
function freshenProject(project: ProjectInfo, gitMarkerDirectory: string | null): ProjectInfo {
|
|
36
|
+
if (!gitMarkerDirectory) return project;
|
|
37
|
+
const headPath = resolveHeadPath(gitMarkerDirectory);
|
|
38
|
+
if (!headPath) return project;
|
|
39
|
+
const mtime = readHeadMtime(headPath);
|
|
40
|
+
if (mtime === undefined) return project;
|
|
41
|
+
freshnessByProject.set(project, { headPath, headMtimeMs: mtime });
|
|
42
|
+
return project;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function resolveHeadPath(gitMarkerDirectory: string): string | undefined {
|
|
46
|
+
const context = resolveGitCommandContext(gitMarkerDirectory);
|
|
47
|
+
const gitDir = context.gitDir
|
|
48
|
+
? resolve(context.cwd, context.gitDir)
|
|
49
|
+
: join(gitMarkerDirectory, ".git");
|
|
50
|
+
return join(gitDir, "HEAD");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function readHeadMtime(headPath: string | undefined): number | undefined {
|
|
54
|
+
if (!headPath) return undefined;
|
|
55
|
+
try {
|
|
56
|
+
return statSync(headPath).mtimeMs;
|
|
57
|
+
} catch {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function isFreshnessCurrent(project: ProjectInfo): boolean {
|
|
63
|
+
if (!project.isGitRepository) return true;
|
|
64
|
+
const meta = freshnessByProject.get(project);
|
|
65
|
+
if (!meta?.headPath || meta.headMtimeMs === undefined) return false;
|
|
66
|
+
return readHeadMtime(meta.headPath) === meta.headMtimeMs;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function invalidateCachedProject(cache: Map<string, ProjectInfo>, project: ProjectInfo): void {
|
|
70
|
+
for (const [key, value] of [...cache]) {
|
|
71
|
+
if (value === project) cache.delete(key);
|
|
72
|
+
}
|
|
73
|
+
freshnessByProject.delete(project);
|
|
74
|
+
}
|
|
75
|
+
|
|
21
76
|
export function resolveProjectInfo(currentPath: string, cache = new Map<string, ProjectInfo>(), timeoutMs?: number): ProjectInfo {
|
|
22
77
|
const normalizedPath = normalizePathForLookup(currentPath);
|
|
23
78
|
const cached = lookupCachedProject(normalizedPath, cache);
|
|
24
|
-
if (cached) {
|
|
79
|
+
if (cached && isFreshnessCurrent(cached)) {
|
|
25
80
|
return cached;
|
|
26
81
|
}
|
|
82
|
+
if (cached) {
|
|
83
|
+
invalidateCachedProject(cache, cached);
|
|
84
|
+
}
|
|
27
85
|
|
|
28
86
|
const visited: string[] = [];
|
|
29
87
|
const searchStart = resolveSearchStart(normalizedPath);
|
|
@@ -41,7 +99,7 @@ export function resolveProjectInfo(currentPath: string, cache = new Map<string,
|
|
|
41
99
|
cache.set(cacheKey, project);
|
|
42
100
|
}
|
|
43
101
|
|
|
44
|
-
return project;
|
|
102
|
+
return project.isGitRepository ? freshenProject(project, gitMarkerDirectory) : project;
|
|
45
103
|
}
|
|
46
104
|
|
|
47
105
|
export function buildCompactProjectLabel(project: Pick<ProjectInfo, "displayName" | "isGitRepository" | "branch" | "isWorktree">): string {
|
|
@@ -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
|
+
}
|
|
@@ -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),
|