@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,535 @@
|
|
|
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
|
+
* In-memory state store for the daemon.
|
|
9
|
+
* Maintains all session data and emits typed StoreEvent events on changes.
|
|
10
|
+
*/
|
|
11
|
+
export class DaemonStore extends EventEmitter {
|
|
12
|
+
private state: DaemonState = {
|
|
13
|
+
tmuxSessions: [],
|
|
14
|
+
sessions: [],
|
|
15
|
+
collectorStatus: {},
|
|
16
|
+
usage: { providers: [], lastRefreshedAt: 0 },
|
|
17
|
+
startedAt: Date.now(),
|
|
18
|
+
refreshedAt: 0,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** Maps SDK process PID → instance URL for PID-ancestry-based deduplication */
|
|
22
|
+
private sdkPids = new Map<number, string>();
|
|
23
|
+
|
|
24
|
+
/** Session IDs that were deliberately excluded as local-command (batch) transcripts */
|
|
25
|
+
private localCommandIds = new Map<string, number>();
|
|
26
|
+
|
|
27
|
+
/** Session IDs whose idle transition was suppressed because of active children.
|
|
28
|
+
* When the last child goes inactive, the parent is transitioned to idle. */
|
|
29
|
+
private suppressedIdle = new Set<string>();
|
|
30
|
+
|
|
31
|
+
constructor(private readonly logger: Logger = NOOP_LOGGER) {
|
|
32
|
+
super();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ── Reads ──────────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
getState(): Readonly<DaemonState> {
|
|
38
|
+
return this.state;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
getSessions(): SessionModel[] {
|
|
42
|
+
return [...this.state.sessions];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
getSession(id: string): SessionModel | undefined {
|
|
46
|
+
return this.state.sessions.find((s) => s.id === id);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
getTmuxSessions(): SessionInfo[] {
|
|
50
|
+
return [...this.state.tmuxSessions];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ── Writes ─────────────────────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
/** Replace the full sessions list and emit diff events */
|
|
56
|
+
setSessions(sessions: SessionModel[]): void {
|
|
57
|
+
// Prune suppressedIdle entries that no longer exist
|
|
58
|
+
const sessionIds = new Set(sessions.map((s) => s.id));
|
|
59
|
+
for (const id of this.suppressedIdle) {
|
|
60
|
+
if (!sessionIds.has(id)) {
|
|
61
|
+
this.suppressedIdle.delete(id);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const previous = new Map(this.state.sessions.map((s) => [s.id, s]));
|
|
66
|
+
const next = new Map(sessions.map((s) => [s.id, s]));
|
|
67
|
+
|
|
68
|
+
// Removed
|
|
69
|
+
for (const [id] of previous) {
|
|
70
|
+
if (!next.has(id)) {
|
|
71
|
+
this.logger.info("store", "Session removed", { event: "session.removed", sessionId: id });
|
|
72
|
+
this.emit("store:event", { type: "session:removed", sessionId: id } satisfies StoreEvent);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Added or updated
|
|
77
|
+
for (const [id, session] of next) {
|
|
78
|
+
if (!previous.has(id)) {
|
|
79
|
+
this.logger.info("store", "Session added", { event: "session.added", sessionId: id, tool: session.tool, state: session.state });
|
|
80
|
+
this.emit("store:event", { type: "session:added", session } satisfies StoreEvent);
|
|
81
|
+
} else {
|
|
82
|
+
const prev = previous.get(id)!;
|
|
83
|
+
if (prev.state !== session.state) {
|
|
84
|
+
this.logger.info("store", "Session state changed", {
|
|
85
|
+
event: "session.state_changed",
|
|
86
|
+
sessionId: id,
|
|
87
|
+
previousState: prev.state,
|
|
88
|
+
state: session.state,
|
|
89
|
+
tool: session.tool,
|
|
90
|
+
});
|
|
91
|
+
} else {
|
|
92
|
+
this.logger.debug("store", "Session updated", { event: "session.updated", sessionId: id, state: session.state });
|
|
93
|
+
}
|
|
94
|
+
this.emit("store:event", { type: "session:updated", session } satisfies StoreEvent);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
this.state = { ...this.state, sessions, refreshedAt: Date.now() };
|
|
99
|
+
this.emit("store:event", { type: "state:refreshed", timestamp: this.state.refreshedAt } satisfies StoreEvent);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Upsert a single session */
|
|
103
|
+
upsertSession(incoming: SessionModel): void {
|
|
104
|
+
const existing = this.state.sessions.find((s) => s.id === incoming.id);
|
|
105
|
+
|
|
106
|
+
// If the existing session has been enriched with tmux context (legacyPaneId
|
|
107
|
+
// is set) but the incoming write does not carry tmux context, preserve the
|
|
108
|
+
// enrichment fields. This prevents the opencode SDK/DB/cluade collectors from
|
|
109
|
+
// wiping out tmux enrichment on every poll/SSE cycle.
|
|
110
|
+
let session = incoming;
|
|
111
|
+
if (existing?.legacyPaneId && !incoming.legacyPaneId) {
|
|
112
|
+
const stableForMs = existing.state !== incoming.state ? 0 : existing.stableForMs;
|
|
113
|
+
session = {
|
|
114
|
+
...incoming,
|
|
115
|
+
source: existing.source,
|
|
116
|
+
project: existing.project ?? incoming.project,
|
|
117
|
+
tmux: existing.tmux,
|
|
118
|
+
legacyPaneId: existing.legacyPaneId,
|
|
119
|
+
processTree: existing.processTree,
|
|
120
|
+
processChain: existing.processChain,
|
|
121
|
+
sample: existing.sample,
|
|
122
|
+
stableForMs,
|
|
123
|
+
panePid: existing.panePid,
|
|
124
|
+
tty: existing.tty,
|
|
125
|
+
paneTitle: existing.paneTitle,
|
|
126
|
+
currentCommand: existing.currentCommand,
|
|
127
|
+
currentPath: existing.currentPath ?? incoming.currentPath,
|
|
128
|
+
};
|
|
129
|
+
} else if (existing?.tmux && !incoming.tmux && existing.id === incoming.id) {
|
|
130
|
+
// Debug: detect when tmux enrichment would be lost because legacyPaneId
|
|
131
|
+
// guard didn't trigger but tmux data exists on existing session
|
|
132
|
+
this.logger.warn("store", "TMUX enrichment at risk", {
|
|
133
|
+
event: "session.enrichment_at_risk",
|
|
134
|
+
sessionId: incoming.id,
|
|
135
|
+
existingSource: existing.source,
|
|
136
|
+
incomingSource: incoming.source,
|
|
137
|
+
existingLegacyPaneId: existing.legacyPaneId,
|
|
138
|
+
incomingLegacyPaneId: incoming.legacyPaneId,
|
|
139
|
+
existingTmux: !!existing.tmux,
|
|
140
|
+
incomingTmux: !!incoming.tmux,
|
|
141
|
+
existingDetectionSource: existing.detectionSource,
|
|
142
|
+
incomingDetectionSource: incoming.detectionSource,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Preserve stateChangedAt from the stored session when the incoming model
|
|
147
|
+
// doesn't carry it (e.g. built by processTranscript from file state only).
|
|
148
|
+
if (existing && !session.stateChangedAt && existing.stateChangedAt) {
|
|
149
|
+
session = { ...session, stateChangedAt: existing.stateChangedAt };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (existing && existing.state !== session.state) {
|
|
153
|
+
const now = Date.now();
|
|
154
|
+
const hasActiveChildren = session.parentSessionId === undefined
|
|
155
|
+
&& session.state === "idle"
|
|
156
|
+
&& (existing.state === "working" || existing.state === "waiting-input")
|
|
157
|
+
&& this.state.sessions.some(
|
|
158
|
+
(s) => s.parentSessionId === session.id
|
|
159
|
+
&& (s.state === "working" || s.state === "waiting-input"),
|
|
160
|
+
);
|
|
161
|
+
if (hasActiveChildren) {
|
|
162
|
+
this.suppressedIdle.add(session.id);
|
|
163
|
+
session = { ...session, state: existing.state, stateChangedAt: existing.stateChangedAt };
|
|
164
|
+
} else {
|
|
165
|
+
this.suppressedIdle.delete(session.id);
|
|
166
|
+
const isGracePeriodIdleToWorking = existing.state === "idle"
|
|
167
|
+
&& session.state === "working"
|
|
168
|
+
&& existing.lastChildActiveAt
|
|
169
|
+
&& (now - existing.lastChildActiveAt) < 1500;
|
|
170
|
+
|
|
171
|
+
if (session.state === "working") {
|
|
172
|
+
session = {
|
|
173
|
+
...session,
|
|
174
|
+
stableForMs: 0,
|
|
175
|
+
stateChangedAt: now,
|
|
176
|
+
lastWorkingChangedAt: isGracePeriodIdleToWorking
|
|
177
|
+
? (existing.lastWorkingChangedAt ?? now)
|
|
178
|
+
: now,
|
|
179
|
+
};
|
|
180
|
+
} else {
|
|
181
|
+
session = { ...session, stableForMs: 0, stateChangedAt: now };
|
|
182
|
+
|
|
183
|
+
if (session.state === "waiting-input" || session.state === "error") {
|
|
184
|
+
session = { ...session, lastWorkingChangedAt: undefined, lastChildActiveAt: undefined };
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (existing) {
|
|
191
|
+
if (existing.state !== session.state) {
|
|
192
|
+
this.logger.info("store", "Session state changed", {
|
|
193
|
+
event: "session.state_changed",
|
|
194
|
+
sessionId: session.id,
|
|
195
|
+
previousState: existing.state,
|
|
196
|
+
state: session.state,
|
|
197
|
+
tool: session.tool,
|
|
198
|
+
});
|
|
199
|
+
} else {
|
|
200
|
+
this.logger.debug("store", "Session updated", { event: "session.updated", sessionId: session.id, state: session.state });
|
|
201
|
+
}
|
|
202
|
+
this.state = {
|
|
203
|
+
...this.state,
|
|
204
|
+
sessions: this.state.sessions.map((s) => (s.id === session.id ? session : s)),
|
|
205
|
+
refreshedAt: Date.now(),
|
|
206
|
+
};
|
|
207
|
+
this.emit("store:event", { type: "session:updated", session } satisfies StoreEvent);
|
|
208
|
+
} else {
|
|
209
|
+
if (!session.stateChangedAt) {
|
|
210
|
+
session = { ...session, stateChangedAt: Date.now() };
|
|
211
|
+
}
|
|
212
|
+
if (session.state === "working" && !session.lastWorkingChangedAt) {
|
|
213
|
+
session = { ...session, lastWorkingChangedAt: session.stateChangedAt };
|
|
214
|
+
}
|
|
215
|
+
this.logger.info("store", "Session added", { event: "session.added", sessionId: session.id, tool: session.tool, state: session.state });
|
|
216
|
+
this.state = {
|
|
217
|
+
...this.state,
|
|
218
|
+
sessions: [...this.state.sessions, session],
|
|
219
|
+
refreshedAt: Date.now(),
|
|
220
|
+
};
|
|
221
|
+
this.emit("store:event", { type: "session:added", session } satisfies StoreEvent);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Track lastChildActiveAt on parent when a child session transitions
|
|
225
|
+
// away from "working" or "waiting-input" and no other children are active.
|
|
226
|
+
if (session.parentSessionId && existing && (existing.state === "working" || existing.state === "waiting-input") && existing.state !== session.state) {
|
|
227
|
+
const anyOtherActiveChildren = this.state.sessions.some(
|
|
228
|
+
(s) => s.parentSessionId === session.parentSessionId
|
|
229
|
+
&& s.id !== session.id
|
|
230
|
+
&& (s.state === "working" || s.state === "waiting-input"),
|
|
231
|
+
);
|
|
232
|
+
if (!anyOtherActiveChildren) {
|
|
233
|
+
const parent = this.state.sessions.find((s) => s.id === session.parentSessionId);
|
|
234
|
+
if (parent) {
|
|
235
|
+
let updatedParent: SessionModel;
|
|
236
|
+
if (this.suppressedIdle.has(parent.id)) {
|
|
237
|
+
this.suppressedIdle.delete(parent.id);
|
|
238
|
+
updatedParent = {
|
|
239
|
+
...parent,
|
|
240
|
+
state: "idle" as SessionModel["state"],
|
|
241
|
+
stateChangedAt: Date.now(),
|
|
242
|
+
stableForMs: 0,
|
|
243
|
+
lastChildActiveAt: Date.now(),
|
|
244
|
+
};
|
|
245
|
+
this.logger.info("store", "Session state changed", {
|
|
246
|
+
event: "session.state_changed",
|
|
247
|
+
sessionId: parent.id,
|
|
248
|
+
previousState: parent.state,
|
|
249
|
+
state: "idle",
|
|
250
|
+
tool: parent.tool,
|
|
251
|
+
});
|
|
252
|
+
} else {
|
|
253
|
+
updatedParent = { ...parent, lastChildActiveAt: Date.now() };
|
|
254
|
+
}
|
|
255
|
+
this.state = {
|
|
256
|
+
...this.state,
|
|
257
|
+
sessions: this.state.sessions.map((s) => s.id === parent.id ? updatedParent : s),
|
|
258
|
+
refreshedAt: Date.now(),
|
|
259
|
+
};
|
|
260
|
+
this.emit("store:event", { type: "session:updated", session: updatedParent } satisfies StoreEvent);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Remove a session by id */
|
|
267
|
+
removeSession(sessionId: string): void {
|
|
268
|
+
const session = this.state.sessions.find((s) => s.id === sessionId);
|
|
269
|
+
if (!session) {
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const parentId = session.parentSessionId;
|
|
274
|
+
|
|
275
|
+
this.logger.info("store", "Session removed", { event: "session.removed", sessionId });
|
|
276
|
+
this.state = {
|
|
277
|
+
...this.state,
|
|
278
|
+
sessions: this.state.sessions.filter((s) => s.id !== sessionId),
|
|
279
|
+
refreshedAt: Date.now(),
|
|
280
|
+
};
|
|
281
|
+
this.emit("store:event", { type: "session:removed", sessionId } satisfies StoreEvent);
|
|
282
|
+
|
|
283
|
+
this.suppressedIdle.delete(sessionId);
|
|
284
|
+
|
|
285
|
+
if (parentId && this.suppressedIdle.has(parentId)) {
|
|
286
|
+
const anyOtherActiveChildren = this.state.sessions.some(
|
|
287
|
+
(s) => s.parentSessionId === parentId && (s.state === "working" || s.state === "waiting-input"),
|
|
288
|
+
);
|
|
289
|
+
if (!anyOtherActiveChildren) {
|
|
290
|
+
this.suppressedIdle.delete(parentId);
|
|
291
|
+
const parent = this.state.sessions.find((s) => s.id === parentId);
|
|
292
|
+
if (parent) {
|
|
293
|
+
const updatedParent: SessionModel = {
|
|
294
|
+
...parent,
|
|
295
|
+
state: "idle" as SessionModel["state"],
|
|
296
|
+
stateChangedAt: Date.now(),
|
|
297
|
+
stableForMs: 0,
|
|
298
|
+
};
|
|
299
|
+
this.state = {
|
|
300
|
+
...this.state,
|
|
301
|
+
sessions: this.state.sessions.map((s) => s.id === parentId ? updatedParent : s),
|
|
302
|
+
refreshedAt: Date.now(),
|
|
303
|
+
};
|
|
304
|
+
this.logger.info("store", "Session state changed", {
|
|
305
|
+
event: "session.state_changed",
|
|
306
|
+
sessionId: parentId,
|
|
307
|
+
previousState: parent.state,
|
|
308
|
+
state: "idle",
|
|
309
|
+
tool: parent.tool,
|
|
310
|
+
});
|
|
311
|
+
this.emit("store:event", { type: "session:updated", session: updatedParent } satisfies StoreEvent);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Remove tmux enrichment from any sessions currently bound to a pane, except
|
|
319
|
+
* for an optional winning session that should retain the binding.
|
|
320
|
+
*/
|
|
321
|
+
clearTmuxBindingForPane(paneId: string, exceptSessionId?: string): void {
|
|
322
|
+
let changed = false;
|
|
323
|
+
const sessions = this.state.sessions.map((session) => {
|
|
324
|
+
const boundPaneId = session.legacyPaneId ?? session.tmux?.pane;
|
|
325
|
+
if (boundPaneId !== paneId || session.id === exceptSessionId) {
|
|
326
|
+
return session;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (
|
|
330
|
+
session.detectionSource !== "opencode-sdk"
|
|
331
|
+
&& session.detectionSource !== "opencode-hook"
|
|
332
|
+
&& session.detectionSource !== "opencode-db"
|
|
333
|
+
&& session.detectionSource !== "claude-hook"
|
|
334
|
+
&& session.detectionSource !== "claude-transcript"
|
|
335
|
+
&& session.detectionSource !== "codex-hook"
|
|
336
|
+
&& session.detectionSource !== "codex-transcript"
|
|
337
|
+
) {
|
|
338
|
+
return session;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
changed = true;
|
|
342
|
+
return {
|
|
343
|
+
...session,
|
|
344
|
+
source: "standalone" as const,
|
|
345
|
+
tmux: undefined,
|
|
346
|
+
legacyPaneId: undefined,
|
|
347
|
+
processTree: undefined,
|
|
348
|
+
processChain: undefined,
|
|
349
|
+
sample: undefined,
|
|
350
|
+
stableForMs: undefined,
|
|
351
|
+
panePid: undefined,
|
|
352
|
+
tty: undefined,
|
|
353
|
+
paneTitle: undefined,
|
|
354
|
+
currentCommand: undefined,
|
|
355
|
+
};
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
if (!changed) {
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
this.state = {
|
|
363
|
+
...this.state,
|
|
364
|
+
sessions,
|
|
365
|
+
refreshedAt: Date.now(),
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** Update usage state and emit event */
|
|
370
|
+
setUsage(usage: import("../usage/types").UsageState): void {
|
|
371
|
+
this.state = { ...this.state, usage };
|
|
372
|
+
this.emit("store:event", { type: "usage:updated", usage } satisfies StoreEvent);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** Update tmux session list */
|
|
376
|
+
setTmuxSessions(sessions: SessionInfo[]): void {
|
|
377
|
+
this.state = { ...this.state, tmuxSessions: sessions };
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Snapshot of process data currently held by the process collector, if any.
|
|
382
|
+
* Used by other collectors as a fallback when platform tools (pgrep, lsof)
|
|
383
|
+
* are unavailable.
|
|
384
|
+
*/
|
|
385
|
+
private processCollector?: { processes: { pid: number; command: string }[] };
|
|
386
|
+
|
|
387
|
+
registerProcessCollector(collector: { processes: { pid: number; command: string }[] }): void {
|
|
388
|
+
this.processCollector = collector;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
getCollectorProcessSnapshot(): { pid: number; command: string }[] {
|
|
392
|
+
return this.processCollector?.processes ?? [];
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** Update collector status */
|
|
396
|
+
setCollectorStatus(name: string, status: Partial<CollectorStatus>): void {
|
|
397
|
+
this.state = {
|
|
398
|
+
...this.state,
|
|
399
|
+
collectorStatus: {
|
|
400
|
+
...this.state.collectorStatus,
|
|
401
|
+
[name]: {
|
|
402
|
+
...{ name, running: false, lastRunAt: 0 },
|
|
403
|
+
...this.state.collectorStatus[name],
|
|
404
|
+
...status,
|
|
405
|
+
},
|
|
406
|
+
},
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** Emit a store error event */
|
|
411
|
+
reportError(source: string, message: string): void {
|
|
412
|
+
this.logger.warn("store", "Collector error", { event: "collector.error", collector: source, errorMessage: message });
|
|
413
|
+
this.emit("store:event", { type: "error", source, message } satisfies StoreEvent);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// ── SDK PID registry ───────────────────────────────────────────────────────
|
|
417
|
+
|
|
418
|
+
/** Register a known SDK process PID (used for PID-ancestry dedup) */
|
|
419
|
+
registerSdkPid(pid: number, instanceUrl: string): void {
|
|
420
|
+
this.sdkPids.set(pid, instanceUrl);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** Unregister a SDK process PID */
|
|
424
|
+
unregisterSdkPid(pid: number): void {
|
|
425
|
+
this.sdkPids.delete(pid);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** Get the current set of known SDK process PIDs */
|
|
429
|
+
getSdkPids(): ReadonlyMap<number, string> {
|
|
430
|
+
return this.sdkPids;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// ── Local-command exclusion registry ──────────────────────────────────────
|
|
434
|
+
|
|
435
|
+
/** Mark a session as a deliberately-excluded local-command (batch) transcript */
|
|
436
|
+
markLocalCommand(sessionId: string): void {
|
|
437
|
+
this.localCommandIds.set(sessionId, Date.now());
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** Returns true when the session was excluded as a local-command transcript */
|
|
441
|
+
isLocalCommand(sessionId: string): boolean {
|
|
442
|
+
return this.localCommandIds.has(sessionId);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** Apply bounded retention to inactive sessions and local-command markers. */
|
|
446
|
+
applyRetention(retention: RetentionConfig, now = Date.now()): void {
|
|
447
|
+
this.pruneLocalCommandIds(retention, now);
|
|
448
|
+
|
|
449
|
+
const active = (session: SessionModel) => session.state === "working" || session.state === "waiting-input";
|
|
450
|
+
const maxAgeMs = retention.standaloneMaxAgeHours > 0
|
|
451
|
+
? retention.standaloneMaxAgeHours * 60 * 60 * 1000
|
|
452
|
+
: 0;
|
|
453
|
+
const removeIds = new Set<string>();
|
|
454
|
+
|
|
455
|
+
if (maxAgeMs > 0) {
|
|
456
|
+
for (const session of this.state.sessions) {
|
|
457
|
+
if (active(session)) continue;
|
|
458
|
+
if (session.source !== "standalone") continue;
|
|
459
|
+
if (now - session.lastActivity > maxAgeMs) {
|
|
460
|
+
removeIds.add(session.id);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const maxSessions = retention.maxSessions;
|
|
466
|
+
if (maxSessions > 0) {
|
|
467
|
+
const survivors = this.state.sessions.filter((session) => !removeIds.has(session.id));
|
|
468
|
+
const overflow = survivors.length - maxSessions;
|
|
469
|
+
if (overflow > 0) {
|
|
470
|
+
const candidates = survivors
|
|
471
|
+
.filter((session) => !active(session))
|
|
472
|
+
.sort((a, b) => a.lastActivity - b.lastActivity);
|
|
473
|
+
for (const session of candidates.slice(0, overflow)) {
|
|
474
|
+
removeIds.add(session.id);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
if (removeIds.size === 0) return;
|
|
480
|
+
|
|
481
|
+
for (const id of removeIds) {
|
|
482
|
+
this.suppressedIdle.delete(id);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
this.state = {
|
|
486
|
+
...this.state,
|
|
487
|
+
sessions: this.state.sessions.filter((session) => !removeIds.has(session.id)),
|
|
488
|
+
refreshedAt: now,
|
|
489
|
+
};
|
|
490
|
+
for (const id of removeIds) {
|
|
491
|
+
this.logger.info("store", "Session pruned by retention", { event: "retention.pruned", sessionId: id });
|
|
492
|
+
this.emit("store:event", { type: "session:removed", sessionId: id } satisfies StoreEvent);
|
|
493
|
+
}
|
|
494
|
+
this.emit("store:event", { type: "state:refreshed", timestamp: now } satisfies StoreEvent);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
private pruneLocalCommandIds(retention: RetentionConfig, now: number): void {
|
|
498
|
+
const ttlMs = retention.localCommandTtlHours * 60 * 60 * 1000;
|
|
499
|
+
for (const [sessionId, markedAt] of this.localCommandIds) {
|
|
500
|
+
if (now - markedAt > ttlMs) {
|
|
501
|
+
this.localCommandIds.delete(sessionId);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/** Rebuild childSessionIds on parent sessions that have children */
|
|
507
|
+
syncChildSessionIds(): void {
|
|
508
|
+
const sessions = this.state.sessions;
|
|
509
|
+
const childMap = new Map<string, string[]>();
|
|
510
|
+
for (const s of sessions) {
|
|
511
|
+
if (!s.parentSessionId) continue;
|
|
512
|
+
const siblings = childMap.get(s.parentSessionId) ?? [];
|
|
513
|
+
if (!siblings.includes(s.id)) siblings.push(s.id);
|
|
514
|
+
childMap.set(s.parentSessionId, siblings);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
for (const [parentId, childIds] of childMap) {
|
|
518
|
+
const parent = this.getSession(parentId);
|
|
519
|
+
if (!parent) continue;
|
|
520
|
+
|
|
521
|
+
const sorted = [...childIds].sort();
|
|
522
|
+
const existing = [...(parent.childSessionIds ?? [])].sort();
|
|
523
|
+
if (JSON.stringify(sorted) !== JSON.stringify(existing)) {
|
|
524
|
+
this.upsertSession({ ...parent, childSessionIds: sorted });
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// ── Typed event helper ─────────────────────────────────────────────────────
|
|
530
|
+
|
|
531
|
+
onEvent(listener: (event: StoreEvent) => void): () => void {
|
|
532
|
+
this.on("store:event", listener);
|
|
533
|
+
return () => this.off("store:event", listener);
|
|
534
|
+
}
|
|
535
|
+
}
|
package/src/detect.ts
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import type { DetectionReason, PaneSample, ProcessInfo, ToolKind } from "./types";
|
|
2
|
+
import { compactWhitespace, isMostlyUiNoise, isOpencodeFooterLine, isSuggestionStyleText, pickMeaningfulTail, stripAnsiAndUi } from "./shared/utils";
|
|
3
|
+
|
|
4
|
+
const ACTIVITY_PATTERN = /(thinking|generating|executing|applying|running|streaming|analyzing|^working\s*\(|background terminal running|waited for background terminal|creating|created\s+\d+)/i;
|
|
5
|
+
const PROMPT_PATTERN = /(press enter|continue\?|y\/n|confirm(?:a|as|ar)?\b|waiting for input|type your own answer|send a message|^>\s|^>\b|^\?\s|pregunta clave|^\-\s+¿|^¿)/im;
|
|
6
|
+
const COMPLETION_PATTERN = /(worked for\s+\d|all done|completed|finished|done\b|se cre[oó]\b|cada archivo contiene|ya se escribieron\s+\d+\s+a\s+\d+|contin[uú]o hasta que termine|proceso ya pas[oó]|termin[oó]\s+bien)/i;
|
|
7
|
+
|
|
8
|
+
const TOOL_PATTERNS: Array<{ tool: ToolKind; regex: RegExp; label: string }> = [
|
|
9
|
+
{ tool: "opencode", regex: /(^|\b)(opencode)(\b|$)/i, label: "process matched opencode" },
|
|
10
|
+
{ tool: "claude", regex: /(?:^|\s|\/)(claude)(?:\s|$)/i, label: "process matched claude" },
|
|
11
|
+
{ tool: "codex", regex: /(^|\b)(codexcli|codex)(\b|$)/i, label: "process matched codex" },
|
|
12
|
+
{ tool: "copilotcli", regex: /(^|\b)(copilotcli|copilot)(\b|$)/i, label: "process matched copilot" },
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
const SHELL_PATTERN = /(^|\b)(bash|zsh|fish|sh|tmux)(\b|$)/i;
|
|
16
|
+
|
|
17
|
+
export function detectTool(currentCommand: string, processTree: ProcessInfo[]): { tool: ToolKind; reasons: DetectionReason[] } {
|
|
18
|
+
const reasons: DetectionReason[] = [];
|
|
19
|
+
const candidates = [currentCommand, ...processTree.map((process) => `${process.comm} ${process.command}`)];
|
|
20
|
+
|
|
21
|
+
for (const candidate of candidates) {
|
|
22
|
+
for (const pattern of TOOL_PATTERNS) {
|
|
23
|
+
if (pattern.regex.test(candidate)) {
|
|
24
|
+
reasons.push({
|
|
25
|
+
label: pattern.label,
|
|
26
|
+
detail: compactWhitespace(candidate),
|
|
27
|
+
});
|
|
28
|
+
return { tool: pattern.tool, reasons };
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (candidates.some((candidate) => SHELL_PATTERN.test(candidate))) {
|
|
34
|
+
reasons.push({
|
|
35
|
+
label: "shell foreground detected",
|
|
36
|
+
detail: compactWhitespace(currentCommand || processTree[0]?.command || "shell"),
|
|
37
|
+
});
|
|
38
|
+
return { tool: "shell", reasons };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
reasons.push({
|
|
42
|
+
label: "no supported tool match",
|
|
43
|
+
detail: compactWhitespace(candidates.join(" | ")) || "no command context",
|
|
44
|
+
});
|
|
45
|
+
return { tool: "unknown", reasons };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function previewSignals(sample: PaneSample | null): DetectionReason[] {
|
|
49
|
+
if (!sample || sample.lines.length === 0) {
|
|
50
|
+
return [];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const normalizedLines = sample.lines.map((line) => compactWhitespace(stripAnsiAndUi(line))).filter(Boolean);
|
|
54
|
+
const recentLines = normalizedLines.slice(-8);
|
|
55
|
+
const tailWindow = recentLines.filter((line) => !isMostlyUiNoise(line));
|
|
56
|
+
const preview = pickMeaningfulTail(tailWindow, sample.preview);
|
|
57
|
+
const reasons: DetectionReason[] = [];
|
|
58
|
+
const activityMatch = findMatchingLine(tailWindow, ACTIVITY_PATTERN);
|
|
59
|
+
const promptMatch = findMatchingLine(tailWindow, PROMPT_PATTERN, { excludeSuggestions: true });
|
|
60
|
+
const completionMatch = findMatchingLine(tailWindow, COMPLETION_PATTERN);
|
|
61
|
+
const opencodeFooterMatch = findOpencodeFooterSignal(recentLines);
|
|
62
|
+
|
|
63
|
+
if (activityMatch) {
|
|
64
|
+
reasons.push({ label: "activity keywords", detail: activityMatch });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (promptMatch) {
|
|
68
|
+
reasons.push({ label: "input prompt keywords", detail: promptMatch });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (completionMatch) {
|
|
72
|
+
reasons.push({ label: "completion keywords", detail: completionMatch });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (opencodeFooterMatch) {
|
|
76
|
+
reasons.push({ label: "opencode footer activity", detail: opencodeFooterMatch });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const lastMeaningful = tailWindow.at(-1) ?? preview;
|
|
80
|
+
if (!isSuggestionStyleText(lastMeaningful) && (/[:>]\s*$/.test(lastMeaningful) || /^>\s+/.test(lastMeaningful) || /^\?\s+/.test(lastMeaningful))) {
|
|
81
|
+
reasons.push({ label: "prompt-like tail", detail: lastMeaningful });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return reasons;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function getPreviewMatchPositions(sample: PaneSample | null): { activityIndex: number; promptIndex: number; completionIndex: number } {
|
|
88
|
+
if (!sample || sample.lines.length === 0) {
|
|
89
|
+
return { activityIndex: -1, promptIndex: -1, completionIndex: -1 };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const normalizedLines = sample.lines.map((line) => compactWhitespace(stripAnsiAndUi(line)));
|
|
93
|
+
let activityIndex = -1;
|
|
94
|
+
let promptIndex = -1;
|
|
95
|
+
let completionIndex = -1;
|
|
96
|
+
|
|
97
|
+
for (let index = 0; index < normalizedLines.length; index += 1) {
|
|
98
|
+
const line = normalizedLines[index] ?? "";
|
|
99
|
+
if (!line) {
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (ACTIVITY_PATTERN.test(line) || isOpencodeFooterLine(line)) {
|
|
103
|
+
activityIndex = index;
|
|
104
|
+
}
|
|
105
|
+
if (isMostlyUiNoise(line) && !isOpencodeFooterLine(line)) {
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (PROMPT_PATTERN.test(line) && !isSuggestionStyleText(line)) {
|
|
109
|
+
promptIndex = index;
|
|
110
|
+
}
|
|
111
|
+
if (COMPLETION_PATTERN.test(line)) {
|
|
112
|
+
completionIndex = index;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return { activityIndex, promptIndex, completionIndex };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function findMatchingLine(lines: string[], pattern: RegExp, options?: { excludeSuggestions?: boolean }): string | null {
|
|
120
|
+
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
121
|
+
const line = lines[index] ?? "";
|
|
122
|
+
if (options?.excludeSuggestions && isSuggestionStyleText(line)) {
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (pattern.test(line)) {
|
|
126
|
+
return line;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function findOpencodeFooterSignal(lines: string[]): string | null {
|
|
134
|
+
const tailLines = lines.slice(-3);
|
|
135
|
+
for (let index = tailLines.length - 1; index >= 0; index -= 1) {
|
|
136
|
+
const line = tailLines[index] ?? "";
|
|
137
|
+
if (isOpencodeFooterLine(line)) {
|
|
138
|
+
return line;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return null;
|
|
143
|
+
}
|