@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.
Files changed (129) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +836 -0
  3. package/package.json +75 -0
  4. package/src/api/docs/sessions.keys.ts +6 -0
  5. package/src/api/docs/sessions.ts +81 -0
  6. package/src/api/docs/system.keys.ts +5 -0
  7. package/src/api/docs/system.ts +66 -0
  8. package/src/api/docs/usage.keys.ts +3 -0
  9. package/src/api/docs/usage.ts +24 -0
  10. package/src/api/docs/webhooks.keys.ts +5 -0
  11. package/src/api/docs/webhooks.ts +78 -0
  12. package/src/api/index.ts +18 -0
  13. package/src/api/lib/auth.ts +43 -0
  14. package/src/api/lib/errors.ts +28 -0
  15. package/src/api/lib/route.ts +18 -0
  16. package/src/api/models/claude.ts +4 -0
  17. package/src/api/models/codex.ts +4 -0
  18. package/src/api/models/opencode.ts +11 -0
  19. package/src/api/routes/sessions.ts +47 -0
  20. package/src/api/routes/system.ts +103 -0
  21. package/src/api/routes/usage.ts +15 -0
  22. package/src/api/routes/webhooks/claude.ts +161 -0
  23. package/src/api/routes/webhooks/codex.ts +175 -0
  24. package/src/api/routes/webhooks/opencode.ts +107 -0
  25. package/src/api/routes.ts +93 -0
  26. package/src/api/schemas/common.ts +21 -0
  27. package/src/api/schemas/sessions.ts +72 -0
  28. package/src/api/schemas/usage.ts +50 -0
  29. package/src/api/schemas/webhooks/claude.ts +32 -0
  30. package/src/api/schemas/webhooks/codex.ts +34 -0
  31. package/src/api/schemas/webhooks/opencode.ts +29 -0
  32. package/src/api/services/claude-session.ts +9 -0
  33. package/src/api/services/codex-session.ts +8 -0
  34. package/src/api/services/opencode-session.ts +73 -0
  35. package/src/classifier.ts +206 -0
  36. package/src/cli/commands.ts +498 -0
  37. package/src/cli/prompt.ts +44 -0
  38. package/src/daemon/cli.ts +27 -0
  39. package/src/daemon/collectors/claude/README.md +108 -0
  40. package/src/daemon/collectors/claude/collector.ts +413 -0
  41. package/src/daemon/collectors/claude/discovery.ts +148 -0
  42. package/src/daemon/collectors/claude/hooks.ts +181 -0
  43. package/src/daemon/collectors/claude/index.ts +8 -0
  44. package/src/daemon/collectors/claude/jsonl.ts +85 -0
  45. package/src/daemon/collectors/claude/transcript.ts +246 -0
  46. package/src/daemon/collectors/codex/README.md +147 -0
  47. package/src/daemon/collectors/codex/collector.ts +661 -0
  48. package/src/daemon/collectors/codex/discovery.ts +104 -0
  49. package/src/daemon/collectors/codex/hooks.ts +179 -0
  50. package/src/daemon/collectors/codex/index.ts +7 -0
  51. package/src/daemon/collectors/codex/jsonl.ts +108 -0
  52. package/src/daemon/collectors/codex/transcript.ts +299 -0
  53. package/src/daemon/collectors/git.ts +295 -0
  54. package/src/daemon/collectors/opencode/README.md +106 -0
  55. package/src/daemon/collectors/opencode/collector.ts +543 -0
  56. package/src/daemon/collectors/opencode/discovery.ts +257 -0
  57. package/src/daemon/collectors/opencode/index.ts +5 -0
  58. package/src/daemon/collectors/opencode/session-builder.ts +59 -0
  59. package/src/daemon/collectors/opencode-db.ts +282 -0
  60. package/src/daemon/collectors/process.ts +201 -0
  61. package/src/daemon/collectors/tmux/README.md +128 -0
  62. package/src/daemon/collectors/tmux/collector.ts +472 -0
  63. package/src/daemon/collectors/tmux/enrichment.ts +241 -0
  64. package/src/daemon/collectors/tmux/index.ts +4 -0
  65. package/src/daemon/collectors/tmux/parse.ts +128 -0
  66. package/src/daemon/collectors/usage.ts +193 -0
  67. package/src/daemon/index.ts +207 -0
  68. package/src/daemon/scheduler.ts +86 -0
  69. package/src/daemon/store.ts +535 -0
  70. package/src/detect.ts +143 -0
  71. package/src/hooks/claude.ts +596 -0
  72. package/src/hooks/codex.ts +517 -0
  73. package/src/legacy/classifier.ts +215 -0
  74. package/src/legacy/detect.ts +143 -0
  75. package/src/mcp/client-server.ts +21 -0
  76. package/src/mcp/http-handler.ts +126 -0
  77. package/src/mcp/index.ts +61 -0
  78. package/src/mcp/resources/config-current.ts +19 -0
  79. package/src/mcp/resources/index.ts +11 -0
  80. package/src/mcp/resources/sessions-active.ts +19 -0
  81. package/src/mcp/resources/sessions-all.ts +19 -0
  82. package/src/mcp/schemas/sessions.ts +3 -0
  83. package/src/mcp/server.ts +17 -0
  84. package/src/mcp/services/api-adapter.ts +48 -0
  85. package/src/mcp/services/data-source.ts +22 -0
  86. package/src/mcp/services/store-adapter.ts +40 -0
  87. package/src/mcp/tools/get-active-sessions.ts +16 -0
  88. package/src/mcp/tools/get-session-children.ts +24 -0
  89. package/src/mcp/tools/get-session-messages.ts +24 -0
  90. package/src/mcp/tools/get-session.ts +23 -0
  91. package/src/mcp/tools/index.ts +15 -0
  92. package/src/mcp/tools/list-sessions.ts +16 -0
  93. package/src/neo-blessed.d.ts +4 -0
  94. package/src/oc/index.ts +302 -0
  95. package/src/service/manager.ts +242 -0
  96. package/src/shared/config.ts +478 -0
  97. package/src/shared/ipc.ts +80 -0
  98. package/src/shared/logger.ts +248 -0
  99. package/src/shared/paths.ts +73 -0
  100. package/src/shared/platform.ts +110 -0
  101. package/src/shared/session-view.ts +241 -0
  102. package/src/shared/types.ts +299 -0
  103. package/src/shared/utils.ts +339 -0
  104. package/src/tui/app.ts +920 -0
  105. package/src/tui/client.ts +149 -0
  106. package/src/tui/index.ts +181 -0
  107. package/src/types.ts +113 -0
  108. package/src/usage/README.md +323 -0
  109. package/src/usage/api.ts +99 -0
  110. package/src/usage/client.ts +3 -0
  111. package/src/usage/clients/anthropic-oauth-usage.ts +178 -0
  112. package/src/usage/clients/anthropic-web-usage.ts +245 -0
  113. package/src/usage/clients/claude-cli-probe.ts +187 -0
  114. package/src/usage/clients/codex-oauth-usage.ts +113 -0
  115. package/src/usage/clients/opencode-go-usage.ts +441 -0
  116. package/src/usage/index.ts +19 -0
  117. package/src/usage/mappers.ts +273 -0
  118. package/src/usage/planner.ts +54 -0
  119. package/src/usage/providers/claude.ts +113 -0
  120. package/src/usage/providers/codex.ts +34 -0
  121. package/src/usage/providers/opencode-go.ts +37 -0
  122. package/src/usage/resolver.ts +4 -0
  123. package/src/usage/resolvers/browser-cookies.ts +198 -0
  124. package/src/usage/resolvers/claude-auth.ts +99 -0
  125. package/src/usage/resolvers/claude-cli.ts +24 -0
  126. package/src/usage/resolvers/claude-web.ts +16 -0
  127. package/src/usage/resolvers/codex-auth.ts +117 -0
  128. package/src/usage/resolvers/opencode-go-web.ts +14 -0
  129. 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