@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,106 @@
|
|
|
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
|
|
@@ -0,0 +1,543 @@
|
|
|
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
|
+
);
|
|
262
|
+
const merged: SessionModel = { ...existing, state: updated.state, hasAttention: updated.hasAttention };
|
|
263
|
+
inst.sessions.set(props.sessionID, merged);
|
|
264
|
+
store.upsertSession(merged);
|
|
265
|
+
if (prevState !== merged.state) {
|
|
266
|
+
this.logger.debug("opencode", "Session state changed via SSE", {
|
|
267
|
+
sessionId: props.sessionID,
|
|
268
|
+
from: prevState,
|
|
269
|
+
to: merged.state,
|
|
270
|
+
sdkStatus: props.status.type,
|
|
271
|
+
prevSdkStatus: prevStatus?.type,
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
} else if (payload.type === "session.idle") {
|
|
276
|
+
// Explicit idle signal — treat as status update to idle
|
|
277
|
+
const props = payload.properties as { sessionID?: string } | undefined;
|
|
278
|
+
if (props?.sessionID) {
|
|
279
|
+
const existing = inst.sessions.get(props.sessionID);
|
|
280
|
+
if (existing && existing.state !== "idle") {
|
|
281
|
+
const idleStatus: SessionStatus = { type: "idle" };
|
|
282
|
+
inst.status.set(props.sessionID, idleStatus);
|
|
283
|
+
const merged: SessionModel = { ...existing, state: "idle", hasAttention: false };
|
|
284
|
+
inst.sessions.set(props.sessionID, merged);
|
|
285
|
+
store.upsertSession(merged);
|
|
286
|
+
this.logger.debug("opencode", "Session state changed via SSE", {
|
|
287
|
+
sessionId: props.sessionID,
|
|
288
|
+
from: existing.state,
|
|
289
|
+
to: "idle",
|
|
290
|
+
sdkStatus: "idle",
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
} else if (payload.type === "session.updated" || payload.type === "session.created") {
|
|
295
|
+
// properties.info carries the full Session object — no extra API call needed
|
|
296
|
+
const props = payload.properties as { info?: Session } | undefined;
|
|
297
|
+
if (props?.info) {
|
|
298
|
+
const session = props.info;
|
|
299
|
+
const status = inst.status.get(session.id) ?? { type: "idle" as const };
|
|
300
|
+
const model = buildSessionModel(session, status, inst.url, inst.pendingPermissions, inst.pendingQuestions, this.gitCollector);
|
|
301
|
+
inst.sessions.set(session.id, model);
|
|
302
|
+
inst.status.set(session.id, status);
|
|
303
|
+
store.upsertSession(model);
|
|
304
|
+
// Rebuild parent→child links whenever a session is created or updated
|
|
305
|
+
store.syncChildSessionIds();
|
|
306
|
+
this.logger.debug("opencode", `Session ${payload.type.replace("session.", "")} via SSE`, {
|
|
307
|
+
sessionId: session.id,
|
|
308
|
+
title: session.title,
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
} else if (payload.type === "session.deleted") {
|
|
312
|
+
const props = payload.properties as { info?: { id?: string } } | undefined;
|
|
313
|
+
const sessionId = props?.info?.id;
|
|
314
|
+
if (sessionId) {
|
|
315
|
+
this.logger.debug("opencode", "Session deleted via SSE", { sessionId });
|
|
316
|
+
inst.sessions.delete(sessionId);
|
|
317
|
+
inst.pendingPermissions.delete(sessionId);
|
|
318
|
+
store.removeSession(`opencode:${sessionId}`);
|
|
319
|
+
// Rebuild parent→child links after deletion
|
|
320
|
+
store.syncChildSessionIds();
|
|
321
|
+
}
|
|
322
|
+
} else if (payload.type === "session.error") {
|
|
323
|
+
const props = payload.properties as { sessionID?: string; error?: { name?: string; data?: { message?: string; statusCode?: number } } } | undefined;
|
|
324
|
+
if (props?.sessionID) {
|
|
325
|
+
this.logger.warn("opencode", "Session error via SSE", {
|
|
326
|
+
sessionId: props.sessionID,
|
|
327
|
+
errorName: props.error?.name,
|
|
328
|
+
error: props.error?.data?.message,
|
|
329
|
+
statusCode: props.error?.data?.statusCode,
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
} else if (payload.type === "permission.updated") {
|
|
333
|
+
// permission.updated fires when a tool permission prompt is pending
|
|
334
|
+
// properties IS the Permission object directly
|
|
335
|
+
const perm = payload.properties as { id?: string; sessionID?: string; type?: string; title?: string } | undefined;
|
|
336
|
+
if (perm?.id && perm.sessionID) {
|
|
337
|
+
if (!inst.pendingPermissions.has(perm.sessionID)) {
|
|
338
|
+
inst.pendingPermissions.set(perm.sessionID, new Set());
|
|
339
|
+
}
|
|
340
|
+
inst.pendingPermissions.get(perm.sessionID)!.add(perm.id);
|
|
341
|
+
this.logger.debug("opencode", "Permission requested — session needs attention", {
|
|
342
|
+
sessionId: perm.sessionID,
|
|
343
|
+
permId: perm.id,
|
|
344
|
+
permType: perm.type,
|
|
345
|
+
title: perm.title,
|
|
346
|
+
});
|
|
347
|
+
this.refreshSessionState(inst, perm.sessionID, store);
|
|
348
|
+
}
|
|
349
|
+
} else if (payload.type === "permission.replied") {
|
|
350
|
+
// properties: { sessionID, permissionID, response }
|
|
351
|
+
const props = payload.properties as { sessionID?: string; permissionID?: string; response?: string } | undefined;
|
|
352
|
+
if (props?.permissionID && props.sessionID) {
|
|
353
|
+
inst.pendingPermissions.get(props.sessionID)?.delete(props.permissionID);
|
|
354
|
+
this.logger.debug("opencode", "Permission replied — unblocking session", {
|
|
355
|
+
sessionId: props.sessionID,
|
|
356
|
+
permId: props.permissionID,
|
|
357
|
+
response: props.response,
|
|
358
|
+
});
|
|
359
|
+
this.refreshSessionState(inst, props.sessionID, store);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
} catch (error) {
|
|
364
|
+
if (abort.signal.aborted) return;
|
|
365
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
366
|
+
this.errorCount += 1;
|
|
367
|
+
this.logger.warn("opencode", "SSE disconnected — falling back to polling", { event: "integration.disconnected", instanceUrl: inst.url, transport: "sse", errorMessage: msg });
|
|
368
|
+
store.setCollectorStatus(this.name, { lastError: msg, errorCount: this.errorCount });
|
|
369
|
+
this.startPollFallback(inst);
|
|
370
|
+
}
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
void connect();
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** Re-compute and push updated state for a single session after permission/question change */
|
|
377
|
+
private refreshSessionState(inst: InstanceState, sessionId: string, store: DaemonStore): void {
|
|
378
|
+
const existing = inst.sessions.get(sessionId);
|
|
379
|
+
if (!existing) return;
|
|
380
|
+
const status = inst.status.get(sessionId) ?? { type: "idle" as const };
|
|
381
|
+
const prevState = existing.state;
|
|
382
|
+
const updated = buildSessionModel(
|
|
383
|
+
{ id: sessionId, title: existing.summary, time: { created: 0, updated: existing.lastActivity }, directory: existing.currentPath ?? "" } as Session,
|
|
384
|
+
status,
|
|
385
|
+
inst.url,
|
|
386
|
+
inst.pendingPermissions,
|
|
387
|
+
inst.pendingQuestions,
|
|
388
|
+
);
|
|
389
|
+
const merged: SessionModel = { ...existing, state: updated.state, hasAttention: updated.hasAttention };
|
|
390
|
+
inst.sessions.set(sessionId, merged);
|
|
391
|
+
store.upsertSession(merged);
|
|
392
|
+
if (prevState !== merged.state) {
|
|
393
|
+
this.logger.debug("opencode", "Session state changed via permission/question event", {
|
|
394
|
+
sessionId,
|
|
395
|
+
from: prevState,
|
|
396
|
+
to: merged.state,
|
|
397
|
+
hasAttention: merged.hasAttention,
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
private async loadSingleSession(
|
|
403
|
+
inst: InstanceState,
|
|
404
|
+
client: ReturnType<typeof createOpencodeClient>,
|
|
405
|
+
sessionId: string,
|
|
406
|
+
): Promise<void> {
|
|
407
|
+
const store = this.store!;
|
|
408
|
+
try {
|
|
409
|
+
const [sessionResult, statusMapResult] = await Promise.all([
|
|
410
|
+
client.session.get({ path: { id: sessionId }, throwOnError: false }),
|
|
411
|
+
client.session.status({ throwOnError: false }),
|
|
412
|
+
]);
|
|
413
|
+
const session = sessionResult.data as Session | undefined;
|
|
414
|
+
const statusMap = (statusMapResult.data as { [key: string]: SessionStatus } | undefined) ?? {};
|
|
415
|
+
const status = statusMap[sessionId] ?? { type: "idle" as const };
|
|
416
|
+
if (!session) return;
|
|
417
|
+
|
|
418
|
+
const model = buildSessionModel(session, status, inst.url, inst.pendingPermissions, inst.pendingQuestions, this.gitCollector);
|
|
419
|
+
inst.sessions.set(sessionId, model);
|
|
420
|
+
inst.status.set(sessionId, status);
|
|
421
|
+
store.upsertSession(model);
|
|
422
|
+
} catch {
|
|
423
|
+
// Ignore per-session load errors
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
private startPollFallback(inst: InstanceState): void {
|
|
428
|
+
if (inst.pollTimer) return;
|
|
429
|
+
this.logger.warn("opencode", "Starting poll fallback", { event: "collector.fallback_started", instanceUrl: inst.url, intervalMs: 10_000 });
|
|
430
|
+
inst.pollTimer = setInterval(() => void this.safePollInstance(inst), 10_000);
|
|
431
|
+
void this.safePollInstance(inst);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* skippedPolls in collector status aggregates two counters:
|
|
436
|
+
* - `skippedRefreshes`: global discovery cycles skipped due to concurrent safeRefresh
|
|
437
|
+
* - `inst.skippedPolls`: per-instance poll cycles skipped due to concurrent safePollInstance
|
|
438
|
+
* This reports the sum for the current instance only.
|
|
439
|
+
*/
|
|
440
|
+
private async safePollInstance(inst: InstanceState): Promise<void> {
|
|
441
|
+
const store = this.store!;
|
|
442
|
+
if (inst.polling) {
|
|
443
|
+
inst.skippedPolls = (inst.skippedPolls ?? 0) + 1;
|
|
444
|
+
store.setCollectorStatus(this.name, { skippedPolls: this.skippedRefreshes + (inst.skippedPolls ?? 0) });
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
inst.polling = true;
|
|
448
|
+
const startedAt = Date.now();
|
|
449
|
+
try {
|
|
450
|
+
await this.pollInstance(inst);
|
|
451
|
+
store.setCollectorStatus(this.name, { lastDurationMs: Date.now() - startedAt, skippedPolls: this.skippedRefreshes + (inst.skippedPolls ?? 0), errorCount: this.errorCount });
|
|
452
|
+
} finally {
|
|
453
|
+
inst.polling = false;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
private async pollInstance(inst: InstanceState): Promise<void> {
|
|
458
|
+
const store = this.store!;
|
|
459
|
+
try {
|
|
460
|
+
const client = createOpencodeClient({ baseUrl: inst.url });
|
|
461
|
+
const listResult = await client.session.list({ throwOnError: false });
|
|
462
|
+
if (!listResult.data) return;
|
|
463
|
+
|
|
464
|
+
// Ensure PID is registered even if connectInstance previously failed.
|
|
465
|
+
// Without this, pollInstance keeps sessions alive but sdkPidSet stays
|
|
466
|
+
// empty, causing the tmux collector to create a duplicate fallback entry.
|
|
467
|
+
if (inst.pid && !store.getSdkPids().has(inst.pid)) {
|
|
468
|
+
store.registerSdkPid(inst.pid, inst.url);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const sessions = (listResult.data as Session[]) ?? [];
|
|
472
|
+
const seenIds = new Set<string>();
|
|
473
|
+
|
|
474
|
+
const statusMapResult = await client.session.status({ throwOnError: false });
|
|
475
|
+
const statusMap = (statusMapResult.data as { [key: string]: SessionStatus } | undefined) ?? {};
|
|
476
|
+
|
|
477
|
+
for (const session of sessions) {
|
|
478
|
+
seenIds.add(session.id);
|
|
479
|
+
const status: SessionStatus = statusMap[session.id] ?? { type: "idle" };
|
|
480
|
+
const prevState = inst.sessions.get(session.id)?.state;
|
|
481
|
+
const model = buildSessionModel(session, status, inst.url, inst.pendingPermissions, inst.pendingQuestions, this.gitCollector);
|
|
482
|
+
inst.sessions.set(session.id, model);
|
|
483
|
+
inst.status.set(session.id, status);
|
|
484
|
+
store.upsertSession(model);
|
|
485
|
+
if (prevState !== undefined && prevState !== model.state) {
|
|
486
|
+
this.logger.debug("opencode", "Session state changed via poll", {
|
|
487
|
+
sessionId: session.id,
|
|
488
|
+
from: prevState,
|
|
489
|
+
to: model.state,
|
|
490
|
+
sdkStatus: status.type,
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// Remove sessions no longer in list
|
|
496
|
+
for (const [id] of inst.sessions) {
|
|
497
|
+
if (!seenIds.has(id)) {
|
|
498
|
+
inst.sessions.delete(id);
|
|
499
|
+
inst.pendingPermissions.delete(id);
|
|
500
|
+
inst.pendingQuestions.delete(id);
|
|
501
|
+
store.removeSession(`opencode:${id}`);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
this.logger.debug("opencode", "Poll cycle complete", { event: "collector.poll_completed", collector: this.name, instanceUrl: inst.url, sessions: sessions.length });
|
|
506
|
+
store.setCollectorStatus(this.name, { running: true, lastRunAt: Date.now() });
|
|
507
|
+
// Rebuild parent→child links after each poll cycle
|
|
508
|
+
store.syncChildSessionIds();
|
|
509
|
+
} catch (error) {
|
|
510
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
511
|
+
this.errorCount += 1;
|
|
512
|
+
this.logger.warn("opencode", "Poll error", { event: "collector.poll_failed", collector: this.name, instanceUrl: inst.url, errorMessage: msg });
|
|
513
|
+
store.setCollectorStatus(this.name, {
|
|
514
|
+
lastError: msg,
|
|
515
|
+
errorCount: this.errorCount,
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
private teardownInstance(inst: InstanceState): void {
|
|
521
|
+
this.logger.info("opencode", "Tearing down instance", { event: "integration.teardown", instanceUrl: inst.url, sessions: inst.sessions.size });
|
|
522
|
+
if (inst.sseAbort) {
|
|
523
|
+
inst.sseAbort.abort();
|
|
524
|
+
}
|
|
525
|
+
if (inst.pollTimer) {
|
|
526
|
+
clearInterval(inst.pollTimer);
|
|
527
|
+
}
|
|
528
|
+
if (inst.pid) {
|
|
529
|
+
this.store?.unregisterSdkPid(inst.pid);
|
|
530
|
+
clearPidPortCache(inst.pid);
|
|
531
|
+
}
|
|
532
|
+
const store = this.store;
|
|
533
|
+
if (store) {
|
|
534
|
+
for (const [id] of inst.sessions) {
|
|
535
|
+
store.removeSession(`opencode:${id}`);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
inst.sessions.clear();
|
|
539
|
+
inst.status.clear();
|
|
540
|
+
inst.pendingPermissions.clear();
|
|
541
|
+
inst.pendingQuestions.clear();
|
|
542
|
+
}
|
|
543
|
+
}
|