@scrylog/cli 0.2.0 → 0.3.1

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 (171) hide show
  1. package/README.md +46 -7
  2. package/dist/assets/highlights-eq9cgrbb.scm +604 -0
  3. package/dist/assets/highlights-ghv9g403.scm +205 -0
  4. package/dist/assets/highlights-hk7bwhj4.scm +284 -0
  5. package/dist/assets/highlights-r812a2qc.scm +150 -0
  6. package/dist/assets/highlights-x6tmsnaa.scm +115 -0
  7. package/dist/assets/injections-73j83es3.scm +27 -0
  8. package/dist/assets/parser.worker-xyhf83rf.js +4563 -0
  9. package/dist/assets/tree-sitter-3jzf13jk.wasm +0 -0
  10. package/dist/assets/tree-sitter-javascript-nd0q4pe9.wasm +0 -0
  11. package/dist/assets/tree-sitter-markdown-411r6y9b.wasm +0 -0
  12. package/dist/assets/tree-sitter-markdown_inline-j5349f42.wasm +0 -0
  13. package/dist/assets/tree-sitter-typescript-zxjzwt75.wasm +0 -0
  14. package/dist/assets/tree-sitter-zig-e78zbjpm.wasm +0 -0
  15. package/dist/cli/assets/providers/SOURCES.md +21 -0
  16. package/dist/cli/assets/providers/claude-terminal.png +0 -0
  17. package/dist/cli/assets/providers/claude-terminal.svg +62 -0
  18. package/dist/cli/assets/providers/claude.png +0 -0
  19. package/dist/cli/assets/providers/copilot.png +0 -0
  20. package/dist/cli/assets/providers/copilot.svg +44 -0
  21. package/dist/cli/assets/providers/openai-terminal.png +0 -0
  22. package/dist/cli/assets/providers/openai-terminal.svg +62 -0
  23. package/dist/cli/assets/providers/openai.png +0 -0
  24. package/dist/cli/assets/providers/opencode-terminal.png +0 -0
  25. package/dist/cli/assets/providers/opencode-terminal.svg +62 -0
  26. package/dist/cli/assets/providers/opencode.png +0 -0
  27. package/dist/cli/assets/providers/shell.png +0 -0
  28. package/dist/cli/assets/providers/shell.svg +23 -0
  29. package/dist/cli/assets/providers/unknown.png +0 -0
  30. package/dist/cli/assets/providers/unknown.svg +23 -0
  31. package/dist/cli/index.js +499 -0
  32. package/dist/cli/index.js.map +61 -0
  33. package/dist/daemon/index.js +670 -0
  34. package/dist/daemon/index.js.map +1475 -0
  35. package/dist/hooks/claude.js +6 -0
  36. package/dist/hooks/claude.js.map +11 -0
  37. package/dist/hooks/codex.js +10 -0
  38. package/dist/hooks/codex.js.map +11 -0
  39. package/dist/mcp/index.js +100 -0
  40. package/dist/mcp/index.js.map +231 -0
  41. package/dist/oc/index.js +21 -0
  42. package/dist/oc/index.js.map +10 -0
  43. package/package.json +26 -20
  44. package/src/postinstall.cjs +11 -0
  45. package/src/api/docs/sessions.keys.ts +0 -6
  46. package/src/api/docs/sessions.ts +0 -81
  47. package/src/api/docs/system.keys.ts +0 -5
  48. package/src/api/docs/system.ts +0 -66
  49. package/src/api/docs/usage.keys.ts +0 -3
  50. package/src/api/docs/usage.ts +0 -24
  51. package/src/api/docs/webhooks.keys.ts +0 -5
  52. package/src/api/docs/webhooks.ts +0 -78
  53. package/src/api/index.ts +0 -18
  54. package/src/api/lib/auth.ts +0 -43
  55. package/src/api/lib/errors.ts +0 -28
  56. package/src/api/lib/route.ts +0 -18
  57. package/src/api/models/claude.ts +0 -4
  58. package/src/api/models/codex.ts +0 -4
  59. package/src/api/models/opencode.ts +0 -11
  60. package/src/api/routes/sessions.ts +0 -47
  61. package/src/api/routes/system.ts +0 -103
  62. package/src/api/routes/usage.ts +0 -15
  63. package/src/api/routes/webhooks/claude.ts +0 -141
  64. package/src/api/routes/webhooks/codex.ts +0 -175
  65. package/src/api/routes/webhooks/opencode.ts +0 -111
  66. package/src/api/routes.ts +0 -93
  67. package/src/api/schemas/common.ts +0 -21
  68. package/src/api/schemas/sessions.ts +0 -72
  69. package/src/api/schemas/usage.ts +0 -50
  70. package/src/api/schemas/webhooks/claude.ts +0 -32
  71. package/src/api/schemas/webhooks/codex.ts +0 -34
  72. package/src/api/schemas/webhooks/opencode.ts +0 -29
  73. package/src/api/services/claude-session.ts +0 -9
  74. package/src/api/services/codex-session.ts +0 -8
  75. package/src/api/services/opencode-session.ts +0 -73
  76. package/src/classifier.ts +0 -206
  77. package/src/cli/commands.ts +0 -632
  78. package/src/cli/prompt.ts +0 -44
  79. package/src/daemon/cli.ts +0 -27
  80. package/src/daemon/collectors/claude/README.md +0 -108
  81. package/src/daemon/collectors/claude/collector.ts +0 -423
  82. package/src/daemon/collectors/claude/discovery.ts +0 -148
  83. package/src/daemon/collectors/claude/hooks.ts +0 -181
  84. package/src/daemon/collectors/claude/index.ts +0 -8
  85. package/src/daemon/collectors/claude/jsonl.ts +0 -85
  86. package/src/daemon/collectors/claude/transcript.ts +0 -246
  87. package/src/daemon/collectors/codex/README.md +0 -147
  88. package/src/daemon/collectors/codex/collector.ts +0 -661
  89. package/src/daemon/collectors/codex/discovery.ts +0 -104
  90. package/src/daemon/collectors/codex/hooks.ts +0 -179
  91. package/src/daemon/collectors/codex/index.ts +0 -7
  92. package/src/daemon/collectors/codex/jsonl.ts +0 -108
  93. package/src/daemon/collectors/codex/transcript.ts +0 -299
  94. package/src/daemon/collectors/git.ts +0 -295
  95. package/src/daemon/collectors/opencode/README.md +0 -106
  96. package/src/daemon/collectors/opencode/collector.ts +0 -545
  97. package/src/daemon/collectors/opencode/discovery.ts +0 -257
  98. package/src/daemon/collectors/opencode/index.ts +0 -5
  99. package/src/daemon/collectors/opencode/session-builder.ts +0 -59
  100. package/src/daemon/collectors/opencode-db.ts +0 -282
  101. package/src/daemon/collectors/process.ts +0 -201
  102. package/src/daemon/collectors/tmux/README.md +0 -128
  103. package/src/daemon/collectors/tmux/collector.ts +0 -472
  104. package/src/daemon/collectors/tmux/enrichment.ts +0 -241
  105. package/src/daemon/collectors/tmux/index.ts +0 -4
  106. package/src/daemon/collectors/tmux/parse.ts +0 -128
  107. package/src/daemon/collectors/usage.ts +0 -193
  108. package/src/daemon/index.ts +0 -207
  109. package/src/daemon/scheduler.ts +0 -86
  110. package/src/daemon/store.ts +0 -588
  111. package/src/detect.ts +0 -143
  112. package/src/hooks/claude.ts +0 -596
  113. package/src/hooks/codex.ts +0 -517
  114. package/src/legacy/classifier.ts +0 -215
  115. package/src/legacy/detect.ts +0 -143
  116. package/src/mcp/client-server.ts +0 -21
  117. package/src/mcp/http-handler.ts +0 -126
  118. package/src/mcp/index.ts +0 -61
  119. package/src/mcp/resources/config-current.ts +0 -19
  120. package/src/mcp/resources/index.ts +0 -11
  121. package/src/mcp/resources/sessions-active.ts +0 -19
  122. package/src/mcp/resources/sessions-all.ts +0 -19
  123. package/src/mcp/schemas/sessions.ts +0 -3
  124. package/src/mcp/server.ts +0 -17
  125. package/src/mcp/services/api-adapter.ts +0 -48
  126. package/src/mcp/services/data-source.ts +0 -22
  127. package/src/mcp/services/store-adapter.ts +0 -40
  128. package/src/mcp/tools/get-active-sessions.ts +0 -16
  129. package/src/mcp/tools/get-session-children.ts +0 -24
  130. package/src/mcp/tools/get-session-messages.ts +0 -24
  131. package/src/mcp/tools/get-session.ts +0 -23
  132. package/src/mcp/tools/index.ts +0 -15
  133. package/src/mcp/tools/list-sessions.ts +0 -16
  134. package/src/neo-blessed.d.ts +0 -4
  135. package/src/oc/index.ts +0 -302
  136. package/src/service/manager.ts +0 -284
  137. package/src/shared/config.ts +0 -478
  138. package/src/shared/ipc.ts +0 -80
  139. package/src/shared/logger.ts +0 -248
  140. package/src/shared/paths.ts +0 -73
  141. package/src/shared/platform.ts +0 -110
  142. package/src/shared/session-view.ts +0 -241
  143. package/src/shared/types.ts +0 -299
  144. package/src/shared/utils.ts +0 -339
  145. package/src/tui/app.ts +0 -920
  146. package/src/tui/client.ts +0 -149
  147. package/src/tui/index.ts +0 -217
  148. package/src/types.ts +0 -113
  149. package/src/usage/README.md +0 -323
  150. package/src/usage/api.ts +0 -99
  151. package/src/usage/client.ts +0 -3
  152. package/src/usage/clients/anthropic-oauth-usage.ts +0 -178
  153. package/src/usage/clients/anthropic-web-usage.ts +0 -245
  154. package/src/usage/clients/claude-cli-probe.ts +0 -187
  155. package/src/usage/clients/codex-oauth-usage.ts +0 -113
  156. package/src/usage/clients/opencode-go-usage.ts +0 -441
  157. package/src/usage/index.ts +0 -20
  158. package/src/usage/mappers.ts +0 -273
  159. package/src/usage/planner.ts +0 -54
  160. package/src/usage/providers/claude.ts +0 -113
  161. package/src/usage/providers/codex.ts +0 -34
  162. package/src/usage/providers/opencode-go.ts +0 -37
  163. package/src/usage/resolver.ts +0 -4
  164. package/src/usage/resolvers/browser-cookies.ts +0 -470
  165. package/src/usage/resolvers/claude-auth.ts +0 -108
  166. package/src/usage/resolvers/claude-cli.ts +0 -24
  167. package/src/usage/resolvers/claude-desktop-auth.ts +0 -160
  168. package/src/usage/resolvers/claude-web.ts +0 -16
  169. package/src/usage/resolvers/codex-auth.ts +0 -117
  170. package/src/usage/resolvers/opencode-go-web.ts +0 -14
  171. package/src/usage/types.ts +0 -190
@@ -1,588 +0,0 @@
1
- import { EventEmitter } from "node:events";
2
- import type { Logger } from "../shared/logger";
3
- import { NOOP_LOGGER } from "../shared/logger";
4
- import type { CollectorStatus, DaemonState, SessionInfo, SessionModel, StoreEvent } from "../shared/types";
5
- import type { RetentionConfig } from "../shared/config";
6
-
7
- /**
8
- * Returns true when an incoming claude-hook "working" event represents an explicit
9
- * user response to a pending attention (permission or question), so the attention
10
- * can be cleared. Background working events (unrelated subagent activity) return false.
11
- */
12
- function resolvesAttention(existing: SessionModel, incoming: SessionModel): boolean {
13
- const evt = incoming.claude?.hookEventType;
14
- if (evt === "UserPromptSubmit" || evt === "PermissionDenied") return true;
15
- if (
16
- (evt === "PreToolUse" || evt === "PostToolUse")
17
- && existing.claude?.attentionKind === "permission"
18
- && incoming.claude?.toolName === existing.claude.toolName
19
- ) return true;
20
- return false;
21
- }
22
-
23
- /**
24
- * In-memory state store for the daemon.
25
- * Maintains all session data and emits typed StoreEvent events on changes.
26
- */
27
- export class DaemonStore extends EventEmitter {
28
- private state: DaemonState = {
29
- tmuxSessions: [],
30
- sessions: [],
31
- collectorStatus: {},
32
- usage: { providers: [], lastRefreshedAt: 0 },
33
- startedAt: Date.now(),
34
- refreshedAt: 0,
35
- };
36
-
37
- /** Maps SDK process PID → instance URL for PID-ancestry-based deduplication */
38
- private sdkPids = new Map<number, string>();
39
-
40
- /** Session IDs that were deliberately excluded as local-command (batch) transcripts */
41
- private localCommandIds = new Map<string, number>();
42
-
43
- /** Session IDs whose idle transition was suppressed because of active children.
44
- * When the last child goes inactive, the parent is transitioned to idle. */
45
- private suppressedIdle = new Set<string>();
46
-
47
- constructor(private readonly logger: Logger = NOOP_LOGGER) {
48
- super();
49
- }
50
-
51
- // ── Reads ──────────────────────────────────────────────────────────────────
52
-
53
- getState(): Readonly<DaemonState> {
54
- return this.state;
55
- }
56
-
57
- getSessions(): SessionModel[] {
58
- return [...this.state.sessions];
59
- }
60
-
61
- getSession(id: string): SessionModel | undefined {
62
- return this.state.sessions.find((s) => s.id === id);
63
- }
64
-
65
- getTmuxSessions(): SessionInfo[] {
66
- return [...this.state.tmuxSessions];
67
- }
68
-
69
- // ── Writes ─────────────────────────────────────────────────────────────────
70
-
71
- /** Replace the full sessions list and emit diff events */
72
- setSessions(sessions: SessionModel[]): void {
73
- // Prune suppressedIdle entries that no longer exist
74
- const sessionIds = new Set(sessions.map((s) => s.id));
75
- for (const id of this.suppressedIdle) {
76
- if (!sessionIds.has(id)) {
77
- this.suppressedIdle.delete(id);
78
- }
79
- }
80
-
81
- const previous = new Map(this.state.sessions.map((s) => [s.id, s]));
82
- const next = new Map(sessions.map((s) => [s.id, s]));
83
-
84
- // Removed
85
- for (const [id] of previous) {
86
- if (!next.has(id)) {
87
- this.logger.info("store", "Session removed", { event: "session.removed", sessionId: id });
88
- this.emit("store:event", { type: "session:removed", sessionId: id } satisfies StoreEvent);
89
- }
90
- }
91
-
92
- // Added or updated
93
- for (const [id, session] of next) {
94
- if (!previous.has(id)) {
95
- this.logger.info("store", "Session added", { event: "session.added", sessionId: id, tool: session.tool, state: session.state });
96
- this.emit("store:event", { type: "session:added", session } satisfies StoreEvent);
97
- } else {
98
- const prev = previous.get(id)!;
99
- if (prev.state !== session.state) {
100
- this.logger.info("store", "Session state changed", {
101
- event: "session.state_changed",
102
- sessionId: id,
103
- previousState: prev.state,
104
- state: session.state,
105
- tool: session.tool,
106
- });
107
- } else {
108
- this.logger.debug("store", "Session updated", { event: "session.updated", sessionId: id, state: session.state });
109
- }
110
- this.emit("store:event", { type: "session:updated", session } satisfies StoreEvent);
111
- }
112
- }
113
-
114
- this.state = { ...this.state, sessions, refreshedAt: Date.now() };
115
- this.emit("store:event", { type: "state:refreshed", timestamp: this.state.refreshedAt } satisfies StoreEvent);
116
- }
117
-
118
- /** Upsert a single session */
119
- upsertSession(incoming: SessionModel): void {
120
- const existing = this.state.sessions.find((s) => s.id === incoming.id);
121
-
122
- // If the existing session has been enriched with tmux context (legacyPaneId
123
- // is set) but the incoming write does not carry tmux context, preserve the
124
- // enrichment fields. This prevents the opencode SDK/DB/cluade collectors from
125
- // wiping out tmux enrichment on every poll/SSE cycle.
126
- let session = incoming;
127
- if (existing?.legacyPaneId && !incoming.legacyPaneId) {
128
- const stableForMs = existing.state !== incoming.state ? 0 : existing.stableForMs;
129
- session = {
130
- ...incoming,
131
- source: existing.source,
132
- project: existing.project ?? incoming.project,
133
- tmux: existing.tmux,
134
- legacyPaneId: existing.legacyPaneId,
135
- processTree: existing.processTree,
136
- processChain: existing.processChain,
137
- sample: existing.sample,
138
- stableForMs,
139
- panePid: existing.panePid,
140
- tty: existing.tty,
141
- paneTitle: existing.paneTitle,
142
- currentCommand: existing.currentCommand,
143
- currentPath: existing.currentPath ?? incoming.currentPath,
144
- };
145
- } else if (existing?.tmux && !incoming.tmux && existing.id === incoming.id) {
146
- // Debug: detect when tmux enrichment would be lost because legacyPaneId
147
- // guard didn't trigger but tmux data exists on existing session
148
- this.logger.warn("store", "TMUX enrichment at risk", {
149
- event: "session.enrichment_at_risk",
150
- sessionId: incoming.id,
151
- existingSource: existing.source,
152
- incomingSource: incoming.source,
153
- existingLegacyPaneId: existing.legacyPaneId,
154
- incomingLegacyPaneId: incoming.legacyPaneId,
155
- existingTmux: !!existing.tmux,
156
- incomingTmux: !!incoming.tmux,
157
- existingDetectionSource: existing.detectionSource,
158
- incomingDetectionSource: incoming.detectionSource,
159
- });
160
- }
161
-
162
- // For all sessions: preserve a previously-resolved project/currentPath when
163
- // the incoming model lacks them. Project resolution can transiently fail
164
- // (Claude slug decode returning null, OpenCode directory not yet populated),
165
- // and once resolved the association must not oscillate back to undefined.
166
- // An incoming model that carries a non-empty project still wins.
167
- if (existing && !session.project && existing.project) {
168
- session = { ...session, project: existing.project };
169
- }
170
- if (existing && !session.currentPath && existing.currentPath) {
171
- session = { ...session, currentPath: existing.currentPath };
172
- }
173
-
174
- // Preserve stateChangedAt from the stored session when the incoming model
175
- // doesn't carry it (e.g. built by processTranscript from file state only).
176
- if (existing && !session.stateChangedAt && existing.stateChangedAt) {
177
- session = { ...session, stateChangedAt: existing.stateChangedAt };
178
- }
179
-
180
- if (existing && existing.state !== session.state) {
181
- const now = Date.now();
182
- const hasActiveChildren = session.parentSessionId === undefined
183
- && session.state === "idle"
184
- && (existing.state === "working" || existing.state === "waiting-input")
185
- && this.state.sessions.some(
186
- (s) => s.parentSessionId === session.id
187
- && (s.state === "working" || s.state === "waiting-input"),
188
- );
189
- if (hasActiveChildren) {
190
- this.suppressedIdle.add(session.id);
191
- session = { ...session, state: existing.state, stateChangedAt: existing.stateChangedAt };
192
- } else {
193
- this.suppressedIdle.delete(session.id);
194
-
195
- // For hook-managed Claude sessions: pending attention (waiting-input +
196
- // hasAttention) takes precedence over concurrent background working events
197
- // until the user explicitly responds. Background subagent activity must not
198
- // clear the permission/question from the dashboard.
199
- const pendingAttention = existing.state === "waiting-input"
200
- && existing.hasAttention === true
201
- && session.state === "working"
202
- && session.detectionSource === "claude-hook"
203
- && !resolvesAttention(existing, session);
204
-
205
- if (pendingAttention) {
206
- // Revert to waiting-input, advancing only the claude metadata (hookSeq etc.)
207
- // so subsequent events are not rejected as stale.
208
- session = {
209
- ...session,
210
- state: "waiting-input" as SessionModel["state"],
211
- hasAttention: true,
212
- summary: existing.summary,
213
- activeTool: existing.activeTool,
214
- stateChangedAt: existing.stateChangedAt,
215
- stableForMs: existing.stableForMs,
216
- };
217
- } else {
218
- const isGracePeriodIdleToWorking = existing.state === "idle"
219
- && session.state === "working"
220
- && existing.lastChildActiveAt
221
- && (now - existing.lastChildActiveAt) < 1500;
222
-
223
- if (session.state === "working") {
224
- session = {
225
- ...session,
226
- stableForMs: 0,
227
- stateChangedAt: now,
228
- lastWorkingChangedAt: isGracePeriodIdleToWorking
229
- ? (existing.lastWorkingChangedAt ?? now)
230
- : now,
231
- };
232
- } else {
233
- session = { ...session, stableForMs: 0, stateChangedAt: now };
234
-
235
- if (session.state === "waiting-input" || session.state === "error") {
236
- session = { ...session, lastWorkingChangedAt: undefined, lastChildActiveAt: undefined };
237
- }
238
- }
239
- }
240
- }
241
- }
242
-
243
- if (existing) {
244
- if (existing.state !== session.state) {
245
- this.logger.info("store", "Session state changed", {
246
- event: "session.state_changed",
247
- sessionId: session.id,
248
- previousState: existing.state,
249
- state: session.state,
250
- tool: session.tool,
251
- });
252
- } else {
253
- this.logger.debug("store", "Session updated", { event: "session.updated", sessionId: session.id, state: session.state });
254
- }
255
- this.state = {
256
- ...this.state,
257
- sessions: this.state.sessions.map((s) => (s.id === session.id ? session : s)),
258
- refreshedAt: Date.now(),
259
- };
260
- this.emit("store:event", { type: "session:updated", session } satisfies StoreEvent);
261
- } else {
262
- if (!session.stateChangedAt) {
263
- session = { ...session, stateChangedAt: Date.now() };
264
- }
265
- if (session.state === "working" && !session.lastWorkingChangedAt) {
266
- session = { ...session, lastWorkingChangedAt: session.stateChangedAt };
267
- }
268
- this.logger.info("store", "Session added", { event: "session.added", sessionId: session.id, tool: session.tool, state: session.state });
269
- this.state = {
270
- ...this.state,
271
- sessions: [...this.state.sessions, session],
272
- refreshedAt: Date.now(),
273
- };
274
- this.emit("store:event", { type: "session:added", session } satisfies StoreEvent);
275
- }
276
-
277
- // Track lastChildActiveAt on parent when a child session transitions
278
- // away from "working" or "waiting-input" and no other children are active.
279
- if (session.parentSessionId && existing && (existing.state === "working" || existing.state === "waiting-input") && existing.state !== session.state) {
280
- const anyOtherActiveChildren = this.state.sessions.some(
281
- (s) => s.parentSessionId === session.parentSessionId
282
- && s.id !== session.id
283
- && (s.state === "working" || s.state === "waiting-input"),
284
- );
285
- if (!anyOtherActiveChildren) {
286
- const parent = this.state.sessions.find((s) => s.id === session.parentSessionId);
287
- if (parent) {
288
- let updatedParent: SessionModel;
289
- if (this.suppressedIdle.has(parent.id)) {
290
- this.suppressedIdle.delete(parent.id);
291
- updatedParent = {
292
- ...parent,
293
- state: "idle" as SessionModel["state"],
294
- stateChangedAt: Date.now(),
295
- stableForMs: 0,
296
- lastChildActiveAt: Date.now(),
297
- };
298
- this.logger.info("store", "Session state changed", {
299
- event: "session.state_changed",
300
- sessionId: parent.id,
301
- previousState: parent.state,
302
- state: "idle",
303
- tool: parent.tool,
304
- });
305
- } else {
306
- updatedParent = { ...parent, lastChildActiveAt: Date.now() };
307
- }
308
- this.state = {
309
- ...this.state,
310
- sessions: this.state.sessions.map((s) => s.id === parent.id ? updatedParent : s),
311
- refreshedAt: Date.now(),
312
- };
313
- this.emit("store:event", { type: "session:updated", session: updatedParent } satisfies StoreEvent);
314
- }
315
- }
316
- }
317
- }
318
-
319
- /** Remove a session by id */
320
- removeSession(sessionId: string): void {
321
- const session = this.state.sessions.find((s) => s.id === sessionId);
322
- if (!session) {
323
- return;
324
- }
325
-
326
- const parentId = session.parentSessionId;
327
-
328
- this.logger.info("store", "Session removed", { event: "session.removed", sessionId });
329
- this.state = {
330
- ...this.state,
331
- sessions: this.state.sessions.filter((s) => s.id !== sessionId),
332
- refreshedAt: Date.now(),
333
- };
334
- this.emit("store:event", { type: "session:removed", sessionId } satisfies StoreEvent);
335
-
336
- this.suppressedIdle.delete(sessionId);
337
-
338
- if (parentId && this.suppressedIdle.has(parentId)) {
339
- const anyOtherActiveChildren = this.state.sessions.some(
340
- (s) => s.parentSessionId === parentId && (s.state === "working" || s.state === "waiting-input"),
341
- );
342
- if (!anyOtherActiveChildren) {
343
- this.suppressedIdle.delete(parentId);
344
- const parent = this.state.sessions.find((s) => s.id === parentId);
345
- if (parent) {
346
- const updatedParent: SessionModel = {
347
- ...parent,
348
- state: "idle" as SessionModel["state"],
349
- stateChangedAt: Date.now(),
350
- stableForMs: 0,
351
- };
352
- this.state = {
353
- ...this.state,
354
- sessions: this.state.sessions.map((s) => s.id === parentId ? updatedParent : s),
355
- refreshedAt: Date.now(),
356
- };
357
- this.logger.info("store", "Session state changed", {
358
- event: "session.state_changed",
359
- sessionId: parentId,
360
- previousState: parent.state,
361
- state: "idle",
362
- tool: parent.tool,
363
- });
364
- this.emit("store:event", { type: "session:updated", session: updatedParent } satisfies StoreEvent);
365
- }
366
- }
367
- }
368
- }
369
-
370
- /**
371
- * Remove tmux enrichment from any sessions currently bound to a pane, except
372
- * for an optional winning session that should retain the binding.
373
- */
374
- clearTmuxBindingForPane(paneId: string, exceptSessionId?: string): void {
375
- let changed = false;
376
- const sessions = this.state.sessions.map((session) => {
377
- const boundPaneId = session.legacyPaneId ?? session.tmux?.pane;
378
- if (boundPaneId !== paneId || session.id === exceptSessionId) {
379
- return session;
380
- }
381
-
382
- if (
383
- session.detectionSource !== "opencode-sdk"
384
- && session.detectionSource !== "opencode-hook"
385
- && session.detectionSource !== "opencode-db"
386
- && session.detectionSource !== "claude-hook"
387
- && session.detectionSource !== "claude-transcript"
388
- && session.detectionSource !== "codex-hook"
389
- && session.detectionSource !== "codex-transcript"
390
- ) {
391
- return session;
392
- }
393
-
394
- changed = true;
395
- return {
396
- ...session,
397
- source: "standalone" as const,
398
- tmux: undefined,
399
- legacyPaneId: undefined,
400
- processTree: undefined,
401
- processChain: undefined,
402
- sample: undefined,
403
- stableForMs: undefined,
404
- panePid: undefined,
405
- tty: undefined,
406
- paneTitle: undefined,
407
- currentCommand: undefined,
408
- };
409
- });
410
-
411
- if (!changed) {
412
- return;
413
- }
414
-
415
- this.state = {
416
- ...this.state,
417
- sessions,
418
- refreshedAt: Date.now(),
419
- };
420
- }
421
-
422
- /** Update usage state and emit event */
423
- setUsage(usage: import("../usage/types").UsageState): void {
424
- this.state = { ...this.state, usage };
425
- this.emit("store:event", { type: "usage:updated", usage } satisfies StoreEvent);
426
- }
427
-
428
- /** Update tmux session list */
429
- setTmuxSessions(sessions: SessionInfo[]): void {
430
- this.state = { ...this.state, tmuxSessions: sessions };
431
- }
432
-
433
- /**
434
- * Snapshot of process data currently held by the process collector, if any.
435
- * Used by other collectors as a fallback when platform tools (pgrep, lsof)
436
- * are unavailable.
437
- */
438
- private processCollector?: { processes: { pid: number; command: string }[] };
439
-
440
- registerProcessCollector(collector: { processes: { pid: number; command: string }[] }): void {
441
- this.processCollector = collector;
442
- }
443
-
444
- getCollectorProcessSnapshot(): { pid: number; command: string }[] {
445
- return this.processCollector?.processes ?? [];
446
- }
447
-
448
- /** Update collector status */
449
- setCollectorStatus(name: string, status: Partial<CollectorStatus>): void {
450
- this.state = {
451
- ...this.state,
452
- collectorStatus: {
453
- ...this.state.collectorStatus,
454
- [name]: {
455
- ...{ name, running: false, lastRunAt: 0 },
456
- ...this.state.collectorStatus[name],
457
- ...status,
458
- },
459
- },
460
- };
461
- }
462
-
463
- /** Emit a store error event */
464
- reportError(source: string, message: string): void {
465
- this.logger.warn("store", "Collector error", { event: "collector.error", collector: source, errorMessage: message });
466
- this.emit("store:event", { type: "error", source, message } satisfies StoreEvent);
467
- }
468
-
469
- // ── SDK PID registry ───────────────────────────────────────────────────────
470
-
471
- /** Register a known SDK process PID (used for PID-ancestry dedup) */
472
- registerSdkPid(pid: number, instanceUrl: string): void {
473
- this.sdkPids.set(pid, instanceUrl);
474
- }
475
-
476
- /** Unregister a SDK process PID */
477
- unregisterSdkPid(pid: number): void {
478
- this.sdkPids.delete(pid);
479
- }
480
-
481
- /** Get the current set of known SDK process PIDs */
482
- getSdkPids(): ReadonlyMap<number, string> {
483
- return this.sdkPids;
484
- }
485
-
486
- // ── Local-command exclusion registry ──────────────────────────────────────
487
-
488
- /** Mark a session as a deliberately-excluded local-command (batch) transcript */
489
- markLocalCommand(sessionId: string): void {
490
- this.localCommandIds.set(sessionId, Date.now());
491
- }
492
-
493
- /** Returns true when the session was excluded as a local-command transcript */
494
- isLocalCommand(sessionId: string): boolean {
495
- return this.localCommandIds.has(sessionId);
496
- }
497
-
498
- /** Apply bounded retention to inactive sessions and local-command markers. */
499
- applyRetention(retention: RetentionConfig, now = Date.now()): void {
500
- this.pruneLocalCommandIds(retention, now);
501
-
502
- const active = (session: SessionModel) => session.state === "working" || session.state === "waiting-input";
503
- const maxAgeMs = retention.standaloneMaxAgeHours > 0
504
- ? retention.standaloneMaxAgeHours * 60 * 60 * 1000
505
- : 0;
506
- const removeIds = new Set<string>();
507
-
508
- if (maxAgeMs > 0) {
509
- for (const session of this.state.sessions) {
510
- if (active(session)) continue;
511
- if (session.source !== "standalone") continue;
512
- if (now - session.lastActivity > maxAgeMs) {
513
- removeIds.add(session.id);
514
- }
515
- }
516
- }
517
-
518
- const maxSessions = retention.maxSessions;
519
- if (maxSessions > 0) {
520
- const survivors = this.state.sessions.filter((session) => !removeIds.has(session.id));
521
- const overflow = survivors.length - maxSessions;
522
- if (overflow > 0) {
523
- const candidates = survivors
524
- .filter((session) => !active(session))
525
- .sort((a, b) => a.lastActivity - b.lastActivity);
526
- for (const session of candidates.slice(0, overflow)) {
527
- removeIds.add(session.id);
528
- }
529
- }
530
- }
531
-
532
- if (removeIds.size === 0) return;
533
-
534
- for (const id of removeIds) {
535
- this.suppressedIdle.delete(id);
536
- }
537
-
538
- this.state = {
539
- ...this.state,
540
- sessions: this.state.sessions.filter((session) => !removeIds.has(session.id)),
541
- refreshedAt: now,
542
- };
543
- for (const id of removeIds) {
544
- this.logger.info("store", "Session pruned by retention", { event: "retention.pruned", sessionId: id });
545
- this.emit("store:event", { type: "session:removed", sessionId: id } satisfies StoreEvent);
546
- }
547
- this.emit("store:event", { type: "state:refreshed", timestamp: now } satisfies StoreEvent);
548
- }
549
-
550
- private pruneLocalCommandIds(retention: RetentionConfig, now: number): void {
551
- const ttlMs = retention.localCommandTtlHours * 60 * 60 * 1000;
552
- for (const [sessionId, markedAt] of this.localCommandIds) {
553
- if (now - markedAt > ttlMs) {
554
- this.localCommandIds.delete(sessionId);
555
- }
556
- }
557
- }
558
-
559
- /** Rebuild childSessionIds on parent sessions that have children */
560
- syncChildSessionIds(): void {
561
- const sessions = this.state.sessions;
562
- const childMap = new Map<string, string[]>();
563
- for (const s of sessions) {
564
- if (!s.parentSessionId) continue;
565
- const siblings = childMap.get(s.parentSessionId) ?? [];
566
- if (!siblings.includes(s.id)) siblings.push(s.id);
567
- childMap.set(s.parentSessionId, siblings);
568
- }
569
-
570
- for (const [parentId, childIds] of childMap) {
571
- const parent = this.getSession(parentId);
572
- if (!parent) continue;
573
-
574
- const sorted = [...childIds].sort();
575
- const existing = [...(parent.childSessionIds ?? [])].sort();
576
- if (JSON.stringify(sorted) !== JSON.stringify(existing)) {
577
- this.upsertSession({ ...parent, childSessionIds: sorted });
578
- }
579
- }
580
- }
581
-
582
- // ── Typed event helper ─────────────────────────────────────────────────────
583
-
584
- onEvent(listener: (event: StoreEvent) => void): () => void {
585
- this.on("store:event", listener);
586
- return () => this.off("store:event", listener);
587
- }
588
- }