@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,201 @@
|
|
|
1
|
+
import type { ScrylogConfig } from "../../shared/config";
|
|
2
|
+
import type { ProcessInfo } from "../../shared/types";
|
|
3
|
+
import { parseIntSafe, runCommand, maybeRunCommand } from "../../shared/utils";
|
|
4
|
+
import { IS_WINDOWS } from "../../shared/platform";
|
|
5
|
+
import type { DaemonStore } from "../store";
|
|
6
|
+
import type { Collector } from "../scheduler";
|
|
7
|
+
import type { Logger } from "../../shared/logger";
|
|
8
|
+
import { NOOP_LOGGER } from "../../shared/logger";
|
|
9
|
+
|
|
10
|
+
export function collectProcesses(timeoutMs?: number): ProcessInfo[] {
|
|
11
|
+
if (IS_WINDOWS) {
|
|
12
|
+
const output = runCommand(["wmic", "process", "get", "ProcessId,ParentProcessId,CommandLine", "/format:csv"], timeoutMs);
|
|
13
|
+
return parseWindowsProcesses(output);
|
|
14
|
+
}
|
|
15
|
+
const output = runCommand(["ps", "-ax", "-o", "pid=,ppid=,state=,etime=,comm=,command="], timeoutMs);
|
|
16
|
+
return parseProcesses(output);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function parseProcesses(output: string): ProcessInfo[] {
|
|
20
|
+
return output
|
|
21
|
+
.split(/\r?\n/)
|
|
22
|
+
.filter(Boolean)
|
|
23
|
+
.map((line) => {
|
|
24
|
+
const match = line.match(/^\s*(\d+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(.*)$/);
|
|
25
|
+
if (!match) {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
pid: parseIntSafe(match[1] ?? ""),
|
|
31
|
+
ppid: parseIntSafe(match[2] ?? ""),
|
|
32
|
+
state: match[3] ?? "",
|
|
33
|
+
elapsed: match[4] ?? "",
|
|
34
|
+
comm: match[5] ?? "",
|
|
35
|
+
command: match[6] ?? "",
|
|
36
|
+
} satisfies ProcessInfo;
|
|
37
|
+
})
|
|
38
|
+
.filter((p): p is ProcessInfo => Boolean(p));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function parseWindowsProcesses(output: string): ProcessInfo[] {
|
|
42
|
+
const lines = output.split(/\r?\n/).filter(Boolean);
|
|
43
|
+
const result: ProcessInfo[] = [];
|
|
44
|
+
|
|
45
|
+
for (const line of lines) {
|
|
46
|
+
// WMIC CSV format: Node,CommandLine,ParentProcessId,ProcessId
|
|
47
|
+
// CommandLine may contain commas, so split from the right.
|
|
48
|
+
const parts = line.split(",");
|
|
49
|
+
if (parts.length < 4) continue;
|
|
50
|
+
|
|
51
|
+
const pidStr = parts[parts.length - 1]?.trim();
|
|
52
|
+
const ppidStr = parts[parts.length - 2]?.trim();
|
|
53
|
+
const command = parts.slice(1, parts.length - 2).join(",").trim();
|
|
54
|
+
const pid = parseIntSafe(pidStr ?? "", 0);
|
|
55
|
+
const ppid = parseIntSafe(ppidStr ?? "", 0);
|
|
56
|
+
if (!pid) continue;
|
|
57
|
+
|
|
58
|
+
result.push({
|
|
59
|
+
pid,
|
|
60
|
+
ppid,
|
|
61
|
+
state: "?",
|
|
62
|
+
elapsed: "",
|
|
63
|
+
comm: "",
|
|
64
|
+
command,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return result;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function buildProcessTree(rootPid: number, processes: ProcessInfo[]): ProcessInfo[] {
|
|
72
|
+
if (!rootPid) {
|
|
73
|
+
return [];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const byParent = new Map<number, ProcessInfo[]>();
|
|
77
|
+
const byPid = new Map<number, ProcessInfo>();
|
|
78
|
+
|
|
79
|
+
for (const process of processes) {
|
|
80
|
+
byPid.set(process.pid, process);
|
|
81
|
+
const existing = byParent.get(process.ppid);
|
|
82
|
+
if (existing) {
|
|
83
|
+
existing.push(process);
|
|
84
|
+
} else {
|
|
85
|
+
byParent.set(process.ppid, [process]);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (!byPid.has(rootPid)) {
|
|
90
|
+
return [];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const seen = new Set<number>();
|
|
94
|
+
const ordered: ProcessInfo[] = [];
|
|
95
|
+
const queue: number[] = [rootPid];
|
|
96
|
+
|
|
97
|
+
while (queue.length > 0) {
|
|
98
|
+
const pid = queue.shift()!;
|
|
99
|
+
if (seen.has(pid)) {
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
seen.add(pid);
|
|
103
|
+
|
|
104
|
+
const process = byPid.get(pid);
|
|
105
|
+
if (process) {
|
|
106
|
+
ordered.push(process);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const children = byParent.get(pid) ?? [];
|
|
110
|
+
children.sort((a, b) => a.pid - b.pid);
|
|
111
|
+
for (const child of children) {
|
|
112
|
+
queue.push(child.pid);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return ordered;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Walk the process tree upward from childPid until we find a PID that exists
|
|
121
|
+
* in the candidate set. Returns the matched PID or undefined.
|
|
122
|
+
* Prevents infinite loops via a visited set.
|
|
123
|
+
*/
|
|
124
|
+
export function findAncestorInSet(
|
|
125
|
+
childPid: number,
|
|
126
|
+
candidateSet: ReadonlySet<number>,
|
|
127
|
+
processes: ProcessInfo[],
|
|
128
|
+
): number | undefined {
|
|
129
|
+
const byPid = new Map(processes.map((p) => [p.pid, p]));
|
|
130
|
+
const visited = new Set<number>();
|
|
131
|
+
let current = childPid;
|
|
132
|
+
|
|
133
|
+
while (current > 1) {
|
|
134
|
+
if (visited.has(current)) break;
|
|
135
|
+
visited.add(current);
|
|
136
|
+
|
|
137
|
+
if (candidateSet.has(current)) return current;
|
|
138
|
+
|
|
139
|
+
const info = byPid.get(current);
|
|
140
|
+
if (!info) break;
|
|
141
|
+
current = info.ppid;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return undefined;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Collector that periodically refreshes the process list.
|
|
149
|
+
* Exposes the latest snapshot for other collectors to use.
|
|
150
|
+
*/
|
|
151
|
+
export class ProcessCollector implements Collector {
|
|
152
|
+
name = "process";
|
|
153
|
+
private timer: ReturnType<typeof setInterval> | null = null;
|
|
154
|
+
private _processes: ProcessInfo[] = [];
|
|
155
|
+
private logger: Logger = NOOP_LOGGER;
|
|
156
|
+
|
|
157
|
+
get processes(): ProcessInfo[] {
|
|
158
|
+
return this._processes;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
start(store: DaemonStore, config: ScrylogConfig, logger: Logger): void {
|
|
162
|
+
this.logger = logger;
|
|
163
|
+
logger.info("process", "Collector started", { event: "collector.started", collector: this.name, intervalMs: config.daemon.pollIntervalMs });
|
|
164
|
+
|
|
165
|
+
const refresh = () => {
|
|
166
|
+
if (IS_WINDOWS) {
|
|
167
|
+
const result = maybeRunCommand(["wmic", "process", "get", "ProcessId,ParentProcessId,CommandLine", "/format:csv"], config.daemon.commandTimeoutMs);
|
|
168
|
+
if (result.ok) {
|
|
169
|
+
this._processes = parseWindowsProcesses(result.output);
|
|
170
|
+
logger.debug("process", "Process list refreshed", { event: "collector.poll_completed", collector: this.name, count: this._processes.length });
|
|
171
|
+
store.setCollectorStatus(this.name, { running: true, lastRunAt: Date.now() });
|
|
172
|
+
} else {
|
|
173
|
+
logger.warn("process", "wmic command failed", { event: "collector.poll_failed", collector: this.name, errorMessage: result.error });
|
|
174
|
+
store.setCollectorStatus(this.name, { lastError: result.error });
|
|
175
|
+
}
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const result = maybeRunCommand(["ps", "-ax", "-o", "pid=,ppid=,state=,etime=,comm=,command="], config.daemon.commandTimeoutMs);
|
|
180
|
+
if (result.ok) {
|
|
181
|
+
this._processes = parseProcesses(result.output);
|
|
182
|
+
logger.debug("process", "Process list refreshed", { event: "collector.poll_completed", collector: this.name, count: this._processes.length });
|
|
183
|
+
store.setCollectorStatus(this.name, { running: true, lastRunAt: Date.now() });
|
|
184
|
+
} else {
|
|
185
|
+
logger.warn("process", "ps command failed", { event: "collector.poll_failed", collector: this.name, errorMessage: result.error });
|
|
186
|
+
store.setCollectorStatus(this.name, { lastError: result.error });
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
refresh();
|
|
191
|
+
this.timer = setInterval(refresh, config.daemon.pollIntervalMs);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
stop(): void {
|
|
195
|
+
if (this.timer) {
|
|
196
|
+
clearInterval(this.timer);
|
|
197
|
+
this.timer = null;
|
|
198
|
+
}
|
|
199
|
+
this.logger.info("process", "Collector stopped", { event: "collector.stopped", collector: this.name });
|
|
200
|
+
}
|
|
201
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# Tmux Collector
|
|
2
|
+
|
|
3
|
+
Scans all tmux panes, classifies their contents and process trees, and produces
|
|
4
|
+
`SessionModel` entries. Acts as a **fallback layer** that enriches sessions
|
|
5
|
+
already tracked by the Claude/OpenCode SDK collectors with pane context
|
|
6
|
+
(session, window, pane IDs, process tree, screen sample, working directory).
|
|
7
|
+
|
|
8
|
+
## Data Flow
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
tmux list-sessions tmux list-panes -a tmux capture-pane
|
|
12
|
+
│ │ │
|
|
13
|
+
│ │ │
|
|
14
|
+
▼ ▼ ▼
|
|
15
|
+
┌──────────────────────────────────────────────────────────┐
|
|
16
|
+
│ parse.ts │
|
|
17
|
+
│ collectTmuxState() ──► parseSessions() + parsePanes() │
|
|
18
|
+
│ capturePane() │
|
|
19
|
+
└──────────────────────────┬───────────────────────────────┘
|
|
20
|
+
│ TmuxCollectionResult
|
|
21
|
+
│
|
|
22
|
+
▼
|
|
23
|
+
┌──────────────────────────────────────────────────────────┐
|
|
24
|
+
│ enrichment.ts │
|
|
25
|
+
│ isClaudeManagedSession() │ isOpenCodeManagedSession() │
|
|
26
|
+
│ detectClaudePermissionPrompt() │
|
|
27
|
+
│ paneModelToSession() │
|
|
28
|
+
│ shouldHideOpenCodePane() │ looksLikeAgent() │
|
|
29
|
+
│ clearStaleClaudeTmuxBindings() │
|
|
30
|
+
│ clearStaleOpenCodeTmuxBindings() │
|
|
31
|
+
└──────────────────────────┬───────────────────────────────┘
|
|
32
|
+
│
|
|
33
|
+
▼
|
|
34
|
+
┌──────────────────────────────────────────────────────────┐
|
|
35
|
+
│ collector.ts │
|
|
36
|
+
│ │
|
|
37
|
+
│ for each pane: │
|
|
38
|
+
│ processTree ◄── ProcessCollector │
|
|
39
|
+
│ │
|
|
40
|
+
│ Matching hierarchy (first match wins): │
|
|
41
|
+
│ ┌──────────────────────────────────────────┐ │
|
|
42
|
+
│ │ M0 @opencode-session pane option │──► enrich │
|
|
43
|
+
│ │ (plugin-written, authoritative) │ │
|
|
44
|
+
│ │ │ │
|
|
45
|
+
│ │ M1 @claude-session pane option │──► enrich │
|
|
46
|
+
│ │ (hook-written, authoritative) │ │
|
|
47
|
+
│ │ + detectClaudePermissionPrompt() │ │
|
|
48
|
+
│ │ │ │
|
|
49
|
+
│ │ M1.5 @codex-session pane option │──► enrich │
|
|
50
|
+
│ │ (hook-written, authoritative) │ │
|
|
51
|
+
│ │ │ │
|
|
52
|
+
│ │ M2 Claude pane candidate │──► fallback│
|
|
53
|
+
│ │ (heuristic, no option set yet) │ │
|
|
54
|
+
│ │ │ │
|
|
55
|
+
│ │ M3 OpenCode pane candidate │──► fallback│
|
|
56
|
+
│ │ (heuristic, no option set yet) │ │
|
|
57
|
+
│ │ │ │
|
|
58
|
+
│ │ M4 SDK PID ancestry match │──► enrich │
|
|
59
|
+
│ │ (via store.getSdkPids()) │ │
|
|
60
|
+
│ │ │ │
|
|
61
|
+
│ │ M5 URL-from-args match │──► enrich │
|
|
62
|
+
│ │ (findOpencodeInTree from process) │ │
|
|
63
|
+
│ │ │ │
|
|
64
|
+
│ │ M6 Generic pane │──► fallback│
|
|
65
|
+
│ │ (classifyPane() from legacy/) │ │
|
|
66
|
+
│ └──────────────────────────────────────────┘ │
|
|
67
|
+
│ │
|
|
68
|
+
│ store.upsertSession() / store.setSessions() │
|
|
69
|
+
│ (setSessions preserves non-fallback, replaces fallback) │
|
|
70
|
+
└──────────────────────────────────────────────────────────┘
|
|
71
|
+
│
|
|
72
|
+
▼
|
|
73
|
+
DaemonStore ──► /api/events SSE ──► TUI
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Modules
|
|
77
|
+
|
|
78
|
+
| File | Responsibility | Key Exports |
|
|
79
|
+
|------|-------------|-------------|
|
|
80
|
+
| `parse.ts` | Run `tmux list-sessions` and `tmux list-panes -a`; parse tab-delimited output; capture pane screen samples | `collectTmuxState()`, `parseSessions()`, `parsePanes()`, `capturePane()`, `PANE_FORMAT`, `SESSION_FORMAT`, `TmuxCollectionResult` |
|
|
81
|
+
| `enrichment.ts` | Predicates to identify managed sessions (Claude/Codex/OpenCode); detect permission prompts from pane sample; decide whether to hide a pane; convert a classified pane into a `SessionModel` | `isClaudeManagedSession()`, `isCodexManagedSession()`, `isOpenCodeManagedSession()`, `detectClaudePermissionPrompt()`, `detectCodexPermissionPrompt()`, `isCodexPaneCandidate()`, `shouldHideOpenCodePane()`, `paneModelToSession()`, `managedStableForMs()`, `clearStaleClaudeTmuxBindings()`, `clearStaleCodexTmuxBindings()`, `clearStaleOpenCodeTmuxBindings()` |
|
|
82
|
+
| `collector.ts` | Iterate over all panes; build process trees; run the 7-tier matching hierarchy; prune stale tmux bindings | `TmuxCollector` (implements `Collector`) |
|
|
83
|
+
| `index.ts` | Public re-exports | `TmuxCollector`, enrichment + parse functions |
|
|
84
|
+
|
|
85
|
+
## Matching Hierarchy Detail
|
|
86
|
+
|
|
87
|
+
The collector processes each pane independently. For every pane it builds the
|
|
88
|
+
process tree (descendants of `pane_pid`) and then attempts each match in order:
|
|
89
|
+
|
|
90
|
+
| Tier | Match | What Happens | Rationale |
|
|
91
|
+
|------|-------|--------------|-----------|
|
|
92
|
+
| **M0** | `@opencode-session` pane user option present | Enrich the matching SDK session with tmux metadata (pane ID, process tree, sample, etc.) | Exact session ID from the scrylog OpenCode plugin — authoritative |
|
|
93
|
+
| **M1** | `@claude-session` pane user option present | Enrich the matching Claude session with tmux metadata + apply `detectClaudePermissionPrompt()` only if not hook-managed | Exact session ID from scrylog-claude-hook — authoritative |
|
|
94
|
+
| **M1.5** | `@codex-session` pane user option present | Enrich the matching Codex session with tmux metadata + apply `detectCodexPermissionPrompt()` only if not hook-managed | Exact session ID from scrylog-codex-hook — authoritative |
|
|
95
|
+
| **M2** | Process tree / command contains `claude` (but no pane option yet) | Create a **fallback** `SessionModel` with `detectionSource: "fallback-detection"` | Heuristic; may be upgraded to M1 later when the hook fires |
|
|
96
|
+
| **M3** | Process tree / command indicates OpenCode (but no pane option yet) | Create a **fallback** `SessionModel` | Heuristic; may be upgraded to M0 later when the plugin fires |
|
|
97
|
+
| **M4** | A PID in the process tree is an ancestor of a known SDK PID (`store.getSdkPids()`) | Enrich all SDK sessions belonging to that instance URL | PID-ancestry deduplication — prevents duplicate rows for the same physical pane |
|
|
98
|
+
| **M5** | A process in the tree is an OpenCode server with a derivable URL (`findOpencodeInTree()`) | Enrich all SDK sessions for that instance URL | URL-based fallback when `registerSdkPid` hasn't happened yet |
|
|
99
|
+
| **M6** | None of the above match | Create a **fallback** `SessionModel` via `classifyPane()` from `legacy/classifier.ts` | Generic shell/editor pane |
|
|
100
|
+
|
|
101
|
+
**Important:** Fallback sessions (`detectionSource: "fallback-detection"`) are
|
|
102
|
+
replaced wholesale each poll cycle. Enriched sessions (M0, M1, M4, M5) are
|
|
103
|
+
upserted and persist across polls.
|
|
104
|
+
|
|
105
|
+
## `stableForMs` for Managed Sessions
|
|
106
|
+
|
|
107
|
+
When a Claude/OpenCode session is enriched with tmux data, `stableForMs` is
|
|
108
|
+
computed from the session's own `stateChangedAt` / `lastActivity` — **not** from
|
|
109
|
+
the pane classifier's heuristic timer. This prevents the TUI timer from jumping
|
|
110
|
+
to minutes the instant tmux enrichment happens.
|
|
111
|
+
|
|
112
|
+
## Interactions
|
|
113
|
+
|
|
114
|
+
| Component | How |
|
|
115
|
+
|-----------|-----|
|
|
116
|
+
| `ProcessCollector` | `buildProcessTree(panePid, allProcesses)` — gets full descendant tree of the pane's root process |
|
|
117
|
+
| `GitCollector` | Resolves `currentPath` → `project` for every pane before classification |
|
|
118
|
+
| `ClaudeCollector` | Supplies hook-managed sessions that M1 matches against; `store.isLocalCommand()` filters excluded batch-run sessions |
|
|
119
|
+
| `CodexCollector` | Supplies hook-managed sessions that M1.5 matches against |
|
|
120
|
+
| `OpenCodeCollector` | Supplies SDK sessions that M0/M4/M5 match against; `store.registerSdkPid()` populates the PID map used in M4 |
|
|
121
|
+
| `legacy/classifier.ts` | `classifyPane()` — M6 fallback heuristics for tool/state/summary detection |
|
|
122
|
+
| `Scheduler` | Calls `start()` → sets up poll interval from `config.daemon.pollIntervalMs` |
|
|
123
|
+
|
|
124
|
+
## Detection Sources
|
|
125
|
+
|
|
126
|
+
This collector **does not** produce its own `detectionSource`. It sets:
|
|
127
|
+
- `source: "tmux"` on enriched sessions (they keep their original `detectionSource`)
|
|
128
|
+
- `source: "tmux"` + `detectionSource: "fallback-detection"` on M2/M3/M6 fallback sessions
|