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