@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,106 +0,0 @@
1
- # OpenCode Collector
2
-
3
- Monitors OpenCode sessions by connecting to running OpenCode instances via the
4
- `@opencode-ai/sdk` HTTP client. Uses SSE live events as the primary transport
5
- with HTTP polling as a fallback.
6
-
7
- ## Data Flow
8
-
9
- ```
10
- config.opencode.instances[]
11
- │
12
- │
13
- ┌─────────────────────────▼──────────────────────────┐
14
- │ discovery │
15
- │ discoverInstances() │ parseOpencodeUrl() │
16
- │ discoverListeningPort() │ findOpencodeInTree() │
17
- └─────────────────────────┬──────────────────────────┘
18
- │ DiscoveredInstance[]
19
- │
20
- ▼
21
- ┌──────────────────────────────────────────────────────────────────┐
22
- │ collector.ts │
23
- │ │
24
- │ ┌───────────────┐ ┌───────────────────────────┐ │
25
- │ │ 60s refresh()│ │ SSE event.subscribe() │ │
26
- │ │ discover + │ │ │ │
27
- │ │ connectInstance() ┌─────►│ session.status │ │
28
- │ └───────┬───────┘ │ │ session.updated/created │ │
29
- │ │ │ │ permission.updated │ │
30
- │ │ ┌───────┴───────┤ permission.replied │ │
31
- │ │ │ poll fallback │ session.deleted │ │
32
- │ │ │ (10s) │ session.error │ │
33
- │ │ └───────┬───────┘ │ │
34
- │ │ │ │ │
35
- │ │ ▼ │ │
36
- │ │ ┌─────────────────┐ │ │
37
- │ └───►│ session-builder │ buildSessionModel() │ │
38
- │ │ │ mapSessionStatus() │ │
39
- │ └────────┬────────┘ │ │
40
- │ │ │ │
41
- │ ▼ │ │
42
- │ store.upsertSession() │ │
43
- │ store.syncChildSessionIds() │ │
44
- │ store.registerSdkPid() │ │
45
- └──────────────────────────────────────────────────────────────────┘
46
- │
47
- ▼
48
- DaemonStore ──► /api/events SSE ──► TUI
49
- ```
50
-
51
- ## Modules
52
-
53
- | File | Responsibility | Key Exports |
54
- |------|-------------|-------------|
55
- | `discovery.ts` | Auto-discover OpenCode processes via `ps`, resolve ephemeral ports with `lsof`, parse command-line URLs | `discoverInstances()`, `parseOpencodeUrl()`, `hasExplicitPort()`, `discoverListeningPort()`, `findOpencodeInTree()`, `clearPidPortCache()`, `DiscoveredInstance` |
56
- | `session-builder.ts` | Map SDK `Session` + `SessionStatus` → `SessionModel`; compute `state` and `hasAttention` from pending permissions/questions | `buildSessionModel()`, `mapSessionStatus()`, `InstanceState` |
57
- | `collector.ts` | Maintain instance connections; subscribe to SSE; handle reconnects and poll fallback; dispatch events to `session-builder` | `OpenCodeCollector` (implements `Collector`) |
58
- | `index.ts` | Public re-exports | `OpenCodeCollector`, discovery + session-builder functions |
59
-
60
- ## State Mapping (`session-builder.ts`)
61
-
62
- | SDK `SessionStatus` | State | Attention |
63
- |---------------------|-------|-----------|
64
- | `busy` | `working` | `false` |
65
- | `idle` | `idle` | `false` |
66
- | `retry` | `working` | `false` |
67
- | *(any + pending permission/question)* | `waiting-input` | `true` |
68
-
69
- ## Transports
70
-
71
- | Transport | Trigger | Events Handled |
72
- |-----------|---------|----------------|
73
- | **SSE** | Primary. Established on `connectInstance()`. | `session.status`, `session.updated`, `session.created`, `session.deleted`, `session.idle`, `session.error`, `permission.updated`, `permission.replied` |
74
- | **Poll** | Fallback. Triggered when SSE disconnects or `connectInstance()` fails. | Fetches full session list + status map every 10s. |
75
-
76
- The collector seeds pending permissions on initial connect by calling the
77
- (optional) `permission.list` SDK endpoint. This avoids stale "waiting-input"
78
- states before the first SSE event arrives.
79
-
80
- ## Instance Lifecycle
81
-
82
- ```
83
- discovered ──► connectInstance() ──► subscribeSSE()
84
- │ │
85
- │ ▼ (on disconnect/error)
86
- │ startPollFallback() ──► pollInstance()
87
- │ │
88
- │ ▼ (on re-connect)
89
- │ stop poll timer ──► subscribeSSE()
90
- │ │
91
- └──────────────────────────────────────────┘
92
- teardownInstance() on instance gone
93
- ```
94
-
95
- ## Interactions
96
-
97
- | Component | How |
98
- |-----------|-----|
99
- | `GitCollector` | Resolves `session.directory` → `project` in `buildSessionModel()` |
100
- | `TmuxCollector` | Uses `parseOpencodeUrl()` to derive instance URLs from pane process trees; uses `store.registerSdkPid()` results for PID-ancestry matching |
101
- | `DaemonStore` | Upserts sessions; registers SDK PIDs for tmux deduplication; rebuilds parent→child links |
102
- | `Scheduler` | Calls `start()` → sets up `60s` discovery interval |
103
-
104
- ## Detection Source Produced
105
-
106
- - `"opencode-sdk"` — session discovered and maintained via `@opencode-ai/sdk` HTTP/SSE
@@ -1,545 +0,0 @@
1
- import type { ScrylogConfig } from "../../../shared/config";
2
- import type { SessionModel, SessionState } from "../../../shared/types";
3
- import type { DaemonStore } from "../../store";
4
- import type { Collector } from "../../scheduler";
5
- import type { Logger } from "../../../shared/logger";
6
- import { NOOP_LOGGER } from "../../../shared/logger";
7
- import { createOpencodeClient } from "@opencode-ai/sdk";
8
- import type { Session, SessionStatus } from "@opencode-ai/sdk";
9
- import type { GitCollector } from "../git";
10
- import { discoverInstances, type DiscoveredInstance, clearPidPortCache } from "./discovery";
11
- import { buildSessionModel, type InstanceState } from "./session-builder";
12
-
13
- // ── Collector ─────────────────────────────────────────────────────────────────
14
-
15
- export class OpenCodeCollector implements Collector {
16
- name = "opencode";
17
- private instances: InstanceState[] = [];
18
- private store?: DaemonStore;
19
- private config?: ScrylogConfig;
20
- private logger: Logger = NOOP_LOGGER;
21
- private discoveryTimer?: ReturnType<typeof setInterval>;
22
- private gitCollector?: GitCollector;
23
- private refreshing = false;
24
- private skippedRefreshes = 0;
25
- private errorCount = 0;
26
-
27
- constructor(gitCollector?: GitCollector) {
28
- this.gitCollector = gitCollector;
29
- }
30
-
31
- async start(store: DaemonStore, config: ScrylogConfig, logger: Logger): Promise<void> {
32
- this.store = store;
33
- this.config = config;
34
- this.logger = logger;
35
-
36
- if (config.opencode.instances.length > 0) {
37
- logger.info("opencode", "Collector started — using configured instances", {
38
- event: "collector.started",
39
- collector: this.name,
40
- instances: config.opencode.instances,
41
- });
42
- } else if (config.opencode.autoDiscover) {
43
- logger.info("opencode", "Collector started — auto-discover enabled", { event: "collector.started", collector: this.name, autoDiscover: true });
44
- } else {
45
- logger.info("opencode", "Collector started — discovery disabled (no instances configured, autoDiscover=false)", { event: "collector.started", collector: this.name, autoDiscover: false });
46
- }
47
-
48
- await this.safeRefresh();
49
-
50
- // Re-discover instances periodically (in case opencode starts later)
51
- this.discoveryTimer = setInterval(() => void this.safeRefresh(), 60_000);
52
- }
53
-
54
- async stop(): Promise<void> {
55
- if (this.discoveryTimer) {
56
- clearInterval(this.discoveryTimer);
57
- }
58
-
59
- for (const inst of this.instances) {
60
- this.teardownInstance(inst);
61
- }
62
- this.instances = [];
63
-
64
- this.logger.info("opencode", "Collector stopped", { event: "collector.stopped", collector: this.name });
65
- }
66
-
67
- // ── Internal ───────────────────────────────────────────────────────────────
68
-
69
- private async safeRefresh(): Promise<void> {
70
- const store = this.store!;
71
- if (this.refreshing) {
72
- this.skippedRefreshes += 1;
73
- store.setCollectorStatus(this.name, { skippedPolls: this.skippedRefreshes });
74
- return;
75
- }
76
- this.refreshing = true;
77
- const startedAt = Date.now();
78
- try {
79
- await this.refresh();
80
- store.setCollectorStatus(this.name, { lastDurationMs: Date.now() - startedAt, skippedPolls: this.skippedRefreshes, errorCount: this.errorCount });
81
- } finally {
82
- this.refreshing = false;
83
- }
84
- }
85
-
86
- private async refresh(): Promise<void> {
87
- const config = this.config!;
88
- const discovered: DiscoveredInstance[] = [];
89
-
90
- if (config.opencode.instances.length > 0) {
91
- for (const url of config.opencode.instances) {
92
- discovered.push({ url });
93
- }
94
- this.logger.debug("opencode", "Using configured instances", { urls: config.opencode.instances });
95
- } else if (config.opencode.autoDiscover) {
96
- discovered.push(...discoverInstances(this.logger, config.daemon.commandTimeoutMs));
97
- }
98
-
99
- // Connect to new instances
100
- const existingUrls = new Set(this.instances.map((i) => i.url));
101
- for (const disc of discovered) {
102
- if (!existingUrls.has(disc.url)) {
103
- this.logger.debug("opencode", "Connecting to new instance", { url: disc.url, pid: disc.pid });
104
- const inst: InstanceState = {
105
- url: disc.url,
106
- pid: disc.pid,
107
- sessions: new Map(),
108
- status: new Map(),
109
- pendingPermissions: new Map(),
110
- pendingQuestions: new Map(),
111
- };
112
- this.instances.push(inst);
113
- void this.connectInstance(inst);
114
- }
115
- }
116
-
117
- // Tear down instances no longer discovered
118
- const discoveredUrls = new Set(discovered.map((d) => d.url));
119
- for (const inst of this.instances) {
120
- if (!discoveredUrls.has(inst.url)) {
121
- this.logger.debug("opencode", "Removing instance no longer discovered", { url: inst.url });
122
- this.teardownInstance(inst);
123
- this.instances = this.instances.filter((i) => i.url !== inst.url);
124
- }
125
- }
126
- }
127
-
128
- private async connectInstance(inst: InstanceState): Promise<void> {
129
- const store = this.store!;
130
- this.logger.info("opencode", "Connecting to instance", { event: "integration.connecting", instanceUrl: inst.url });
131
-
132
- try {
133
- const client = createOpencodeClient({ baseUrl: inst.url });
134
-
135
- // Load initial sessions
136
- const listResult = await client.session.list({ throwOnError: false });
137
- if (!listResult.data) {
138
- this.logger.warn("opencode", "Instance returned no session data", { event: "integration.invalid_response", instanceUrl: inst.url });
139
- return;
140
- }
141
-
142
- const sessions = (listResult.data as Session[]) ?? [];
143
-
144
- const statusMapResult = await client.session.status({ throwOnError: false });
145
- const statusMap = (statusMapResult.data as { [key: string]: SessionStatus } | undefined) ?? {};
146
-
147
- // Seed pending permissions on connect
148
- let pendingSeedCount = 0;
149
- try {
150
- // Use dynamic access since permission API may not exist on all SDK versions
151
- const permClient = (client as unknown as Record<string, { list: (opts: unknown) => Promise<{ data?: unknown }> }>)["permission"];
152
- if (permClient?.list) {
153
- const permResult = await permClient.list({ throwOnError: false });
154
- const pending = (permResult.data as Array<{ id: string; sessionID: string }> | undefined) ?? [];
155
- for (const req of pending) {
156
- if (!inst.pendingPermissions.has(req.sessionID)) {
157
- inst.pendingPermissions.set(req.sessionID, new Set());
158
- }
159
- inst.pendingPermissions.get(req.sessionID)!.add(req.id);
160
- pendingSeedCount++;
161
- }
162
- if (pendingSeedCount > 0) {
163
- this.logger.debug("opencode", "Seeded pending permissions on connect", { url: inst.url, count: pendingSeedCount });
164
- }
165
- }
166
- } catch {
167
- // permission.list may not exist on older opencode versions; ignore
168
- }
169
-
170
- for (const session of sessions) {
171
- const status: SessionStatus = statusMap[session.id] ?? { type: "idle" };
172
- const model = buildSessionModel(session, status, inst.url, inst.pendingPermissions, inst.pendingQuestions, this.gitCollector);
173
- inst.sessions.set(session.id, model);
174
- inst.status.set(session.id, status);
175
- store.upsertSession(model);
176
- }
177
-
178
- // Rebuild parent→child links after initial load
179
- store.syncChildSessionIds();
180
-
181
- // Register PID in store for PID-ancestry dedup
182
- if (inst.pid) {
183
- store.registerSdkPid(inst.pid, inst.url);
184
- }
185
-
186
- this.logger.info("opencode", "Connected to instance", {
187
- event: "integration.connected",
188
- instanceUrl: inst.url,
189
- sessions: sessions.length,
190
- pendingPermissions: pendingSeedCount,
191
- });
192
-
193
- store.setCollectorStatus(this.name, { running: true, lastRunAt: Date.now() });
194
-
195
- // Subscribe to SSE events
196
- this.subscribeSSE(inst, client);
197
- } catch (error) {
198
- const msg = error instanceof Error ? error.message : String(error);
199
- this.logger.warn("opencode", "Failed to connect to instance", { event: "integration.connect_failed", instanceUrl: inst.url, errorMessage: msg });
200
- store.setCollectorStatus(this.name, { lastError: msg });
201
- // Start polling fallback
202
- this.startPollFallback(inst);
203
- }
204
- }
205
-
206
- private subscribeSSE(
207
- inst: InstanceState,
208
- client: ReturnType<typeof createOpencodeClient>,
209
- ): void {
210
- const store = this.store!;
211
- const abort = new AbortController();
212
- inst.sseAbort = abort;
213
-
214
- const connect = async () => {
215
- try {
216
- // Use client.event.subscribe() (GET /event) instead of client.global.event()
217
- // global.event() only emits server.* events (heartbeat, connected).
218
- // event.subscribe() delivers all session/permission events for this instance.
219
- const sseResult = await client.event.subscribe({ throwOnError: false });
220
- if (!sseResult.stream) {
221
- this.logger.warn("opencode", "SSE stream unavailable", { event: "integration.unavailable", instanceUrl: inst.url, transport: "sse" });
222
- return;
223
- }
224
-
225
- this.logger.info("opencode", "SSE stream subscribed", { event: "integration.subscribed", instanceUrl: inst.url, transport: "sse" });
226
-
227
- // event.subscribe() yields Event directly: { type: string, properties: unknown }
228
- for await (const event of sseResult.stream as AsyncIterable<{
229
- type?: string;
230
- properties?: unknown;
231
- }>) {
232
- if (abort.signal.aborted) break;
233
-
234
- const payload = event;
235
- if (!payload?.type) continue;
236
-
237
- // Log ALL events including heartbeats so we can confirm stream is alive
238
- this.logger.debug("opencode", "SSE event received", {
239
- event: "integration.event_received",
240
- instanceUrl: inst.url,
241
- type: payload.type,
242
- });
243
-
244
- // Skip internal server heartbeat/connected events — not actionable
245
- if (payload.type === "server.heartbeat" || payload.type === "server.connected") continue;
246
-
247
- if (payload.type === "session.status") {
248
- const props = payload.properties as { sessionID: string; status: SessionStatus } | undefined;
249
- if (!props) continue;
250
- const existing = inst.sessions.get(props.sessionID);
251
- if (existing) {
252
- const prevStatus = inst.status.get(props.sessionID);
253
- const prevState = existing.state;
254
- inst.status.set(props.sessionID, props.status);
255
- const updated = buildSessionModel(
256
- { id: props.sessionID, title: existing.summary, time: { created: 0, updated: existing.lastActivity }, directory: existing.currentPath ?? "" } as Session,
257
- props.status,
258
- inst.url,
259
- inst.pendingPermissions,
260
- inst.pendingQuestions,
261
- this.gitCollector,
262
- );
263
- const merged: SessionModel = { ...existing, state: updated.state, hasAttention: updated.hasAttention, project: updated.project ?? existing.project };
264
- inst.sessions.set(props.sessionID, merged);
265
- store.upsertSession(merged);
266
- if (prevState !== merged.state) {
267
- this.logger.debug("opencode", "Session state changed via SSE", {
268
- sessionId: props.sessionID,
269
- from: prevState,
270
- to: merged.state,
271
- sdkStatus: props.status.type,
272
- prevSdkStatus: prevStatus?.type,
273
- });
274
- }
275
- }
276
- } else if (payload.type === "session.idle") {
277
- // Explicit idle signal — treat as status update to idle
278
- const props = payload.properties as { sessionID?: string } | undefined;
279
- if (props?.sessionID) {
280
- const existing = inst.sessions.get(props.sessionID);
281
- if (existing && existing.state !== "idle") {
282
- const idleStatus: SessionStatus = { type: "idle" };
283
- inst.status.set(props.sessionID, idleStatus);
284
- const merged: SessionModel = { ...existing, state: "idle", hasAttention: false };
285
- inst.sessions.set(props.sessionID, merged);
286
- store.upsertSession(merged);
287
- this.logger.debug("opencode", "Session state changed via SSE", {
288
- sessionId: props.sessionID,
289
- from: existing.state,
290
- to: "idle",
291
- sdkStatus: "idle",
292
- });
293
- }
294
- }
295
- } else if (payload.type === "session.updated" || payload.type === "session.created") {
296
- // properties.info carries the full Session object — no extra API call needed
297
- const props = payload.properties as { info?: Session } | undefined;
298
- if (props?.info) {
299
- const session = props.info;
300
- const status = inst.status.get(session.id) ?? { type: "idle" as const };
301
- const model = buildSessionModel(session, status, inst.url, inst.pendingPermissions, inst.pendingQuestions, this.gitCollector);
302
- inst.sessions.set(session.id, model);
303
- inst.status.set(session.id, status);
304
- store.upsertSession(model);
305
- // Rebuild parent→child links whenever a session is created or updated
306
- store.syncChildSessionIds();
307
- this.logger.debug("opencode", `Session ${payload.type.replace("session.", "")} via SSE`, {
308
- sessionId: session.id,
309
- title: session.title,
310
- });
311
- }
312
- } else if (payload.type === "session.deleted") {
313
- const props = payload.properties as { info?: { id?: string } } | undefined;
314
- const sessionId = props?.info?.id;
315
- if (sessionId) {
316
- this.logger.debug("opencode", "Session deleted via SSE", { sessionId });
317
- inst.sessions.delete(sessionId);
318
- inst.pendingPermissions.delete(sessionId);
319
- store.removeSession(`opencode:${sessionId}`);
320
- // Rebuild parent→child links after deletion
321
- store.syncChildSessionIds();
322
- }
323
- } else if (payload.type === "session.error") {
324
- const props = payload.properties as { sessionID?: string; error?: { name?: string; data?: { message?: string; statusCode?: number } } } | undefined;
325
- if (props?.sessionID) {
326
- this.logger.warn("opencode", "Session error via SSE", {
327
- sessionId: props.sessionID,
328
- errorName: props.error?.name,
329
- error: props.error?.data?.message,
330
- statusCode: props.error?.data?.statusCode,
331
- });
332
- }
333
- } else if (payload.type === "permission.updated") {
334
- // permission.updated fires when a tool permission prompt is pending
335
- // properties IS the Permission object directly
336
- const perm = payload.properties as { id?: string; sessionID?: string; type?: string; title?: string } | undefined;
337
- if (perm?.id && perm.sessionID) {
338
- if (!inst.pendingPermissions.has(perm.sessionID)) {
339
- inst.pendingPermissions.set(perm.sessionID, new Set());
340
- }
341
- inst.pendingPermissions.get(perm.sessionID)!.add(perm.id);
342
- this.logger.debug("opencode", "Permission requested — session needs attention", {
343
- sessionId: perm.sessionID,
344
- permId: perm.id,
345
- permType: perm.type,
346
- title: perm.title,
347
- });
348
- this.refreshSessionState(inst, perm.sessionID, store);
349
- }
350
- } else if (payload.type === "permission.replied") {
351
- // properties: { sessionID, permissionID, response }
352
- const props = payload.properties as { sessionID?: string; permissionID?: string; response?: string } | undefined;
353
- if (props?.permissionID && props.sessionID) {
354
- inst.pendingPermissions.get(props.sessionID)?.delete(props.permissionID);
355
- this.logger.debug("opencode", "Permission replied — unblocking session", {
356
- sessionId: props.sessionID,
357
- permId: props.permissionID,
358
- response: props.response,
359
- });
360
- this.refreshSessionState(inst, props.sessionID, store);
361
- }
362
- }
363
- }
364
- } catch (error) {
365
- if (abort.signal.aborted) return;
366
- const msg = error instanceof Error ? error.message : String(error);
367
- this.errorCount += 1;
368
- this.logger.warn("opencode", "SSE disconnected — falling back to polling", { event: "integration.disconnected", instanceUrl: inst.url, transport: "sse", errorMessage: msg });
369
- store.setCollectorStatus(this.name, { lastError: msg, errorCount: this.errorCount });
370
- this.startPollFallback(inst);
371
- }
372
- };
373
-
374
- void connect();
375
- }
376
-
377
- /** Re-compute and push updated state for a single session after permission/question change */
378
- private refreshSessionState(inst: InstanceState, sessionId: string, store: DaemonStore): void {
379
- const existing = inst.sessions.get(sessionId);
380
- if (!existing) return;
381
- const status = inst.status.get(sessionId) ?? { type: "idle" as const };
382
- const prevState = existing.state;
383
- const updated = buildSessionModel(
384
- { id: sessionId, title: existing.summary, time: { created: 0, updated: existing.lastActivity }, directory: existing.currentPath ?? "" } as Session,
385
- status,
386
- inst.url,
387
- inst.pendingPermissions,
388
- inst.pendingQuestions,
389
- this.gitCollector,
390
- );
391
- const merged: SessionModel = { ...existing, state: updated.state, hasAttention: updated.hasAttention, project: updated.project ?? existing.project };
392
- inst.sessions.set(sessionId, merged);
393
- store.upsertSession(merged);
394
- if (prevState !== merged.state) {
395
- this.logger.debug("opencode", "Session state changed via permission/question event", {
396
- sessionId,
397
- from: prevState,
398
- to: merged.state,
399
- hasAttention: merged.hasAttention,
400
- });
401
- }
402
- }
403
-
404
- private async loadSingleSession(
405
- inst: InstanceState,
406
- client: ReturnType<typeof createOpencodeClient>,
407
- sessionId: string,
408
- ): Promise<void> {
409
- const store = this.store!;
410
- try {
411
- const [sessionResult, statusMapResult] = await Promise.all([
412
- client.session.get({ path: { id: sessionId }, throwOnError: false }),
413
- client.session.status({ throwOnError: false }),
414
- ]);
415
- const session = sessionResult.data as Session | undefined;
416
- const statusMap = (statusMapResult.data as { [key: string]: SessionStatus } | undefined) ?? {};
417
- const status = statusMap[sessionId] ?? { type: "idle" as const };
418
- if (!session) return;
419
-
420
- const model = buildSessionModel(session, status, inst.url, inst.pendingPermissions, inst.pendingQuestions, this.gitCollector);
421
- inst.sessions.set(sessionId, model);
422
- inst.status.set(sessionId, status);
423
- store.upsertSession(model);
424
- } catch {
425
- // Ignore per-session load errors
426
- }
427
- }
428
-
429
- private startPollFallback(inst: InstanceState): void {
430
- if (inst.pollTimer) return;
431
- this.logger.warn("opencode", "Starting poll fallback", { event: "collector.fallback_started", instanceUrl: inst.url, intervalMs: 10_000 });
432
- inst.pollTimer = setInterval(() => void this.safePollInstance(inst), 10_000);
433
- void this.safePollInstance(inst);
434
- }
435
-
436
- /**
437
- * skippedPolls in collector status aggregates two counters:
438
- * - `skippedRefreshes`: global discovery cycles skipped due to concurrent safeRefresh
439
- * - `inst.skippedPolls`: per-instance poll cycles skipped due to concurrent safePollInstance
440
- * This reports the sum for the current instance only.
441
- */
442
- private async safePollInstance(inst: InstanceState): Promise<void> {
443
- const store = this.store!;
444
- if (inst.polling) {
445
- inst.skippedPolls = (inst.skippedPolls ?? 0) + 1;
446
- store.setCollectorStatus(this.name, { skippedPolls: this.skippedRefreshes + (inst.skippedPolls ?? 0) });
447
- return;
448
- }
449
- inst.polling = true;
450
- const startedAt = Date.now();
451
- try {
452
- await this.pollInstance(inst);
453
- store.setCollectorStatus(this.name, { lastDurationMs: Date.now() - startedAt, skippedPolls: this.skippedRefreshes + (inst.skippedPolls ?? 0), errorCount: this.errorCount });
454
- } finally {
455
- inst.polling = false;
456
- }
457
- }
458
-
459
- private async pollInstance(inst: InstanceState): Promise<void> {
460
- const store = this.store!;
461
- try {
462
- const client = createOpencodeClient({ baseUrl: inst.url });
463
- const listResult = await client.session.list({ throwOnError: false });
464
- if (!listResult.data) return;
465
-
466
- // Ensure PID is registered even if connectInstance previously failed.
467
- // Without this, pollInstance keeps sessions alive but sdkPidSet stays
468
- // empty, causing the tmux collector to create a duplicate fallback entry.
469
- if (inst.pid && !store.getSdkPids().has(inst.pid)) {
470
- store.registerSdkPid(inst.pid, inst.url);
471
- }
472
-
473
- const sessions = (listResult.data as Session[]) ?? [];
474
- const seenIds = new Set<string>();
475
-
476
- const statusMapResult = await client.session.status({ throwOnError: false });
477
- const statusMap = (statusMapResult.data as { [key: string]: SessionStatus } | undefined) ?? {};
478
-
479
- for (const session of sessions) {
480
- seenIds.add(session.id);
481
- const status: SessionStatus = statusMap[session.id] ?? { type: "idle" };
482
- const prevState = inst.sessions.get(session.id)?.state;
483
- const model = buildSessionModel(session, status, inst.url, inst.pendingPermissions, inst.pendingQuestions, this.gitCollector);
484
- inst.sessions.set(session.id, model);
485
- inst.status.set(session.id, status);
486
- store.upsertSession(model);
487
- if (prevState !== undefined && prevState !== model.state) {
488
- this.logger.debug("opencode", "Session state changed via poll", {
489
- sessionId: session.id,
490
- from: prevState,
491
- to: model.state,
492
- sdkStatus: status.type,
493
- });
494
- }
495
- }
496
-
497
- // Remove sessions no longer in list
498
- for (const [id] of inst.sessions) {
499
- if (!seenIds.has(id)) {
500
- inst.sessions.delete(id);
501
- inst.pendingPermissions.delete(id);
502
- inst.pendingQuestions.delete(id);
503
- store.removeSession(`opencode:${id}`);
504
- }
505
- }
506
-
507
- this.logger.debug("opencode", "Poll cycle complete", { event: "collector.poll_completed", collector: this.name, instanceUrl: inst.url, sessions: sessions.length });
508
- store.setCollectorStatus(this.name, { running: true, lastRunAt: Date.now() });
509
- // Rebuild parent→child links after each poll cycle
510
- store.syncChildSessionIds();
511
- } catch (error) {
512
- const msg = error instanceof Error ? error.message : String(error);
513
- this.errorCount += 1;
514
- this.logger.warn("opencode", "Poll error", { event: "collector.poll_failed", collector: this.name, instanceUrl: inst.url, errorMessage: msg });
515
- store.setCollectorStatus(this.name, {
516
- lastError: msg,
517
- errorCount: this.errorCount,
518
- });
519
- }
520
- }
521
-
522
- private teardownInstance(inst: InstanceState): void {
523
- this.logger.info("opencode", "Tearing down instance", { event: "integration.teardown", instanceUrl: inst.url, sessions: inst.sessions.size });
524
- if (inst.sseAbort) {
525
- inst.sseAbort.abort();
526
- }
527
- if (inst.pollTimer) {
528
- clearInterval(inst.pollTimer);
529
- }
530
- if (inst.pid) {
531
- this.store?.unregisterSdkPid(inst.pid);
532
- clearPidPortCache(inst.pid);
533
- }
534
- const store = this.store;
535
- if (store) {
536
- for (const [id] of inst.sessions) {
537
- store.removeSession(`opencode:${id}`);
538
- }
539
- }
540
- inst.sessions.clear();
541
- inst.status.clear();
542
- inst.pendingPermissions.clear();
543
- inst.pendingQuestions.clear();
544
- }
545
- }