@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.
Files changed (129) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +836 -0
  3. package/package.json +75 -0
  4. package/src/api/docs/sessions.keys.ts +6 -0
  5. package/src/api/docs/sessions.ts +81 -0
  6. package/src/api/docs/system.keys.ts +5 -0
  7. package/src/api/docs/system.ts +66 -0
  8. package/src/api/docs/usage.keys.ts +3 -0
  9. package/src/api/docs/usage.ts +24 -0
  10. package/src/api/docs/webhooks.keys.ts +5 -0
  11. package/src/api/docs/webhooks.ts +78 -0
  12. package/src/api/index.ts +18 -0
  13. package/src/api/lib/auth.ts +43 -0
  14. package/src/api/lib/errors.ts +28 -0
  15. package/src/api/lib/route.ts +18 -0
  16. package/src/api/models/claude.ts +4 -0
  17. package/src/api/models/codex.ts +4 -0
  18. package/src/api/models/opencode.ts +11 -0
  19. package/src/api/routes/sessions.ts +47 -0
  20. package/src/api/routes/system.ts +103 -0
  21. package/src/api/routes/usage.ts +15 -0
  22. package/src/api/routes/webhooks/claude.ts +161 -0
  23. package/src/api/routes/webhooks/codex.ts +175 -0
  24. package/src/api/routes/webhooks/opencode.ts +107 -0
  25. package/src/api/routes.ts +93 -0
  26. package/src/api/schemas/common.ts +21 -0
  27. package/src/api/schemas/sessions.ts +72 -0
  28. package/src/api/schemas/usage.ts +50 -0
  29. package/src/api/schemas/webhooks/claude.ts +32 -0
  30. package/src/api/schemas/webhooks/codex.ts +34 -0
  31. package/src/api/schemas/webhooks/opencode.ts +29 -0
  32. package/src/api/services/claude-session.ts +9 -0
  33. package/src/api/services/codex-session.ts +8 -0
  34. package/src/api/services/opencode-session.ts +73 -0
  35. package/src/classifier.ts +206 -0
  36. package/src/cli/commands.ts +498 -0
  37. package/src/cli/prompt.ts +44 -0
  38. package/src/daemon/cli.ts +27 -0
  39. package/src/daemon/collectors/claude/README.md +108 -0
  40. package/src/daemon/collectors/claude/collector.ts +413 -0
  41. package/src/daemon/collectors/claude/discovery.ts +148 -0
  42. package/src/daemon/collectors/claude/hooks.ts +181 -0
  43. package/src/daemon/collectors/claude/index.ts +8 -0
  44. package/src/daemon/collectors/claude/jsonl.ts +85 -0
  45. package/src/daemon/collectors/claude/transcript.ts +246 -0
  46. package/src/daemon/collectors/codex/README.md +147 -0
  47. package/src/daemon/collectors/codex/collector.ts +661 -0
  48. package/src/daemon/collectors/codex/discovery.ts +104 -0
  49. package/src/daemon/collectors/codex/hooks.ts +179 -0
  50. package/src/daemon/collectors/codex/index.ts +7 -0
  51. package/src/daemon/collectors/codex/jsonl.ts +108 -0
  52. package/src/daemon/collectors/codex/transcript.ts +299 -0
  53. package/src/daemon/collectors/git.ts +295 -0
  54. package/src/daemon/collectors/opencode/README.md +106 -0
  55. package/src/daemon/collectors/opencode/collector.ts +543 -0
  56. package/src/daemon/collectors/opencode/discovery.ts +257 -0
  57. package/src/daemon/collectors/opencode/index.ts +5 -0
  58. package/src/daemon/collectors/opencode/session-builder.ts +59 -0
  59. package/src/daemon/collectors/opencode-db.ts +282 -0
  60. package/src/daemon/collectors/process.ts +201 -0
  61. package/src/daemon/collectors/tmux/README.md +128 -0
  62. package/src/daemon/collectors/tmux/collector.ts +472 -0
  63. package/src/daemon/collectors/tmux/enrichment.ts +241 -0
  64. package/src/daemon/collectors/tmux/index.ts +4 -0
  65. package/src/daemon/collectors/tmux/parse.ts +128 -0
  66. package/src/daemon/collectors/usage.ts +193 -0
  67. package/src/daemon/index.ts +207 -0
  68. package/src/daemon/scheduler.ts +86 -0
  69. package/src/daemon/store.ts +535 -0
  70. package/src/detect.ts +143 -0
  71. package/src/hooks/claude.ts +596 -0
  72. package/src/hooks/codex.ts +517 -0
  73. package/src/legacy/classifier.ts +215 -0
  74. package/src/legacy/detect.ts +143 -0
  75. package/src/mcp/client-server.ts +21 -0
  76. package/src/mcp/http-handler.ts +126 -0
  77. package/src/mcp/index.ts +61 -0
  78. package/src/mcp/resources/config-current.ts +19 -0
  79. package/src/mcp/resources/index.ts +11 -0
  80. package/src/mcp/resources/sessions-active.ts +19 -0
  81. package/src/mcp/resources/sessions-all.ts +19 -0
  82. package/src/mcp/schemas/sessions.ts +3 -0
  83. package/src/mcp/server.ts +17 -0
  84. package/src/mcp/services/api-adapter.ts +48 -0
  85. package/src/mcp/services/data-source.ts +22 -0
  86. package/src/mcp/services/store-adapter.ts +40 -0
  87. package/src/mcp/tools/get-active-sessions.ts +16 -0
  88. package/src/mcp/tools/get-session-children.ts +24 -0
  89. package/src/mcp/tools/get-session-messages.ts +24 -0
  90. package/src/mcp/tools/get-session.ts +23 -0
  91. package/src/mcp/tools/index.ts +15 -0
  92. package/src/mcp/tools/list-sessions.ts +16 -0
  93. package/src/neo-blessed.d.ts +4 -0
  94. package/src/oc/index.ts +302 -0
  95. package/src/service/manager.ts +242 -0
  96. package/src/shared/config.ts +478 -0
  97. package/src/shared/ipc.ts +80 -0
  98. package/src/shared/logger.ts +248 -0
  99. package/src/shared/paths.ts +73 -0
  100. package/src/shared/platform.ts +110 -0
  101. package/src/shared/session-view.ts +241 -0
  102. package/src/shared/types.ts +299 -0
  103. package/src/shared/utils.ts +339 -0
  104. package/src/tui/app.ts +920 -0
  105. package/src/tui/client.ts +149 -0
  106. package/src/tui/index.ts +181 -0
  107. package/src/types.ts +113 -0
  108. package/src/usage/README.md +323 -0
  109. package/src/usage/api.ts +99 -0
  110. package/src/usage/client.ts +3 -0
  111. package/src/usage/clients/anthropic-oauth-usage.ts +178 -0
  112. package/src/usage/clients/anthropic-web-usage.ts +245 -0
  113. package/src/usage/clients/claude-cli-probe.ts +187 -0
  114. package/src/usage/clients/codex-oauth-usage.ts +113 -0
  115. package/src/usage/clients/opencode-go-usage.ts +441 -0
  116. package/src/usage/index.ts +19 -0
  117. package/src/usage/mappers.ts +273 -0
  118. package/src/usage/planner.ts +54 -0
  119. package/src/usage/providers/claude.ts +113 -0
  120. package/src/usage/providers/codex.ts +34 -0
  121. package/src/usage/providers/opencode-go.ts +37 -0
  122. package/src/usage/resolver.ts +4 -0
  123. package/src/usage/resolvers/browser-cookies.ts +198 -0
  124. package/src/usage/resolvers/claude-auth.ts +99 -0
  125. package/src/usage/resolvers/claude-cli.ts +24 -0
  126. package/src/usage/resolvers/claude-web.ts +16 -0
  127. package/src/usage/resolvers/codex-auth.ts +117 -0
  128. package/src/usage/resolvers/opencode-go-web.ts +14 -0
  129. package/src/usage/types.ts +190 -0
@@ -0,0 +1,48 @@
1
+ import { flattenSessionTree } from "../../shared/session-view";
2
+ import type { SessionTreeItem, SessionListItem } from "../../shared/session-view";
3
+ import type { DataSource } from "./data-source";
4
+
5
+ type FetchFn = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
6
+
7
+ export function createApiAdapter(daemonFetch: FetchFn): DataSource {
8
+ return {
9
+ fetchSessions: async () => {
10
+ const response = await daemonFetch("/api/sessions");
11
+ const data = (await response.json()) as { sessions: SessionTreeItem[] };
12
+ return data.sessions;
13
+ },
14
+
15
+ fetchSession: async (id) => {
16
+ const response = await daemonFetch(`/api/sessions/${encodeURIComponent(id)}`);
17
+ if (response.status === 404) return null;
18
+ const data = (await response.json()) as { session: unknown };
19
+ return data.session as any;
20
+ },
21
+
22
+ fetchSessionChildren: async (id) => {
23
+ const response = await daemonFetch(`/api/sessions/${encodeURIComponent(id)}/children`);
24
+ if (response.status === 404) return [];
25
+ const data = (await response.json()) as { children: SessionListItem[] };
26
+ return data.children;
27
+ },
28
+
29
+ fetchSessionMessages: async (id) => {
30
+ const response = await daemonFetch(`/api/sessions/${encodeURIComponent(id)}/messages`);
31
+ if (response.status === 404) return { sessionId: id, messages: [] };
32
+ return await response.json();
33
+ },
34
+
35
+ fetchActiveSessions: async () => {
36
+ const response = await daemonFetch("/api/sessions");
37
+ const data = (await response.json()) as { sessions: SessionTreeItem[] };
38
+ return flattenSessionTree(data.sessions).filter(
39
+ (s) => s.state === "working" || s.state === "waiting-input",
40
+ );
41
+ },
42
+
43
+ fetchConfig: async () => {
44
+ const response = await daemonFetch("/api/config");
45
+ return await response.json();
46
+ },
47
+ };
48
+ }
@@ -0,0 +1,22 @@
1
+ import type { SessionTreeItem, SessionListItem } from "../../shared/session-view";
2
+ import type { SessionModel } from "../../shared/types";
3
+
4
+ /**
5
+ * Uniform data-access interface for MCP tools and resources.
6
+ * Both the in-daemon server and the stdio binary implement this
7
+ * via StoreAdapter and ApiAdapter respectively.
8
+ */
9
+ export interface DataSource {
10
+ /** Fetch the full visible session tree (roots with children arrays). */
11
+ fetchSessions(): Promise<SessionTreeItem[]>;
12
+ /** Fetch a single session by ID, or null if not found. */
13
+ fetchSession(id: string): Promise<SessionModel | null>;
14
+ /** Fetch active children of a parent session. */
15
+ fetchSessionChildren(id: string): Promise<SessionListItem[]>;
16
+ /** Fetch recent messages for a session (returns messages payload or empty object). */
17
+ fetchSessionMessages(id: string): Promise<unknown>;
18
+ /** Fetch all active (working / waiting-input) sessions flattened. */
19
+ fetchActiveSessions(): Promise<SessionListItem[]>;
20
+ /** Fetch current daemon configuration (sensitive fields redacted). */
21
+ fetchConfig(): Promise<unknown>;
22
+ }
@@ -0,0 +1,40 @@
1
+ import type { DaemonStore } from "../../daemon/store";
2
+ import type { ScrylogConfig } from "../../shared/config";
3
+ import { buildSessionView, flattenSessionTree } from "../../shared/session-view";
4
+ import type { DataSource } from "./data-source";
5
+
6
+ export function createStoreAdapter(store: DaemonStore, config: ScrylogConfig): DataSource {
7
+ function getSessionView() {
8
+ return buildSessionView(store.getSessions(), config);
9
+ }
10
+
11
+ return {
12
+ fetchSessions: async () => getSessionView().sessions,
13
+
14
+ fetchSession: async (id) => store.getSession(id) ?? null,
15
+
16
+ fetchSessionChildren: async (id) => {
17
+ const parent = store.getSession(id);
18
+ if (!parent) return [];
19
+ return getSessionView().sessions.find((s) => s.id === id)?.children ?? [];
20
+ },
21
+
22
+ fetchSessionMessages: async (id) => {
23
+ const session = store.getSession(id);
24
+ if (!session) return { sessionId: id, messages: [] };
25
+ return { sessionId: id, messages: [] };
26
+ },
27
+
28
+ fetchActiveSessions: async () => {
29
+ return flattenSessionTree(getSessionView().sessions).filter(
30
+ (s) => s.state === "working" || s.state === "waiting-input",
31
+ );
32
+ },
33
+
34
+ fetchConfig: async () => ({
35
+ ...config,
36
+ api: { ...config.api, bearerToken: undefined },
37
+ mcp: { ...config.mcp, bearerToken: undefined },
38
+ }),
39
+ };
40
+ }
@@ -0,0 +1,16 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import type { DataSource } from "../services/data-source";
3
+
4
+ export function registerGetActiveSessions(server: McpServer, ds: DataSource) {
5
+ server.tool(
6
+ "get_active_sessions",
7
+ "List sessions that are currently in a working or waiting-input state",
8
+ {},
9
+ async () => {
10
+ const sessions = await ds.fetchActiveSessions();
11
+ return {
12
+ content: [{ type: "text" as const, text: JSON.stringify(sessions, null, 2) }],
13
+ };
14
+ },
15
+ );
16
+ }
@@ -0,0 +1,24 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { SessionIdSchema } from "../schemas/sessions";
3
+ import type { DataSource } from "../services/data-source";
4
+
5
+ export function registerGetSessionChildren(server: McpServer, ds: DataSource) {
6
+ server.tool(
7
+ "get_session_children",
8
+ "Get all active sub-agent sessions spawned by a parent session, with full detail",
9
+ { id: SessionIdSchema },
10
+ async ({ id }) => {
11
+ const session = await ds.fetchSession(id);
12
+ if (!session) {
13
+ return {
14
+ isError: true,
15
+ content: [{ type: "text" as const, text: `Session '${id}' not found` }],
16
+ };
17
+ }
18
+ const children = await ds.fetchSessionChildren(id);
19
+ return {
20
+ content: [{ type: "text" as const, text: JSON.stringify({ sessionId: id, children }, null, 2) }],
21
+ };
22
+ },
23
+ );
24
+ }
@@ -0,0 +1,24 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { SessionIdSchema } from "../schemas/sessions";
3
+ import type { DataSource } from "../services/data-source";
4
+
5
+ export function registerGetSessionMessages(server: McpServer, ds: DataSource) {
6
+ server.tool(
7
+ "get_session_messages",
8
+ "Get recent messages for a session (if available from Claude transcript or OpenCode SDK)",
9
+ { id: SessionIdSchema },
10
+ async ({ id }) => {
11
+ const session = await ds.fetchSession(id);
12
+ if (!session) {
13
+ return {
14
+ isError: true,
15
+ content: [{ type: "text" as const, text: `Session '${id}' not found` }],
16
+ };
17
+ }
18
+ const messages = await ds.fetchSessionMessages(id);
19
+ return {
20
+ content: [{ type: "text" as const, text: JSON.stringify(messages, null, 2) }],
21
+ };
22
+ },
23
+ );
24
+ }
@@ -0,0 +1,23 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { SessionIdSchema } from "../schemas/sessions";
3
+ import type { DataSource } from "../services/data-source";
4
+
5
+ export function registerGetSession(server: McpServer, ds: DataSource) {
6
+ server.tool(
7
+ "get_session",
8
+ "Get details for a specific session by ID",
9
+ { id: SessionIdSchema },
10
+ async ({ id }) => {
11
+ const session = await ds.fetchSession(id);
12
+ if (!session) {
13
+ return {
14
+ isError: true,
15
+ content: [{ type: "text" as const, text: `Session '${id}' not found` }],
16
+ };
17
+ }
18
+ return {
19
+ content: [{ type: "text" as const, text: JSON.stringify(session, null, 2) }],
20
+ };
21
+ },
22
+ );
23
+ }
@@ -0,0 +1,15 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import type { DataSource } from "../services/data-source";
3
+ import { registerListSessions } from "./list-sessions";
4
+ import { registerGetActiveSessions } from "./get-active-sessions";
5
+ import { registerGetSession } from "./get-session";
6
+ import { registerGetSessionMessages } from "./get-session-messages";
7
+ import { registerGetSessionChildren } from "./get-session-children";
8
+
9
+ export function registerAllTools(server: McpServer, ds: DataSource) {
10
+ registerListSessions(server, ds);
11
+ registerGetActiveSessions(server, ds);
12
+ registerGetSession(server, ds);
13
+ registerGetSessionMessages(server, ds);
14
+ registerGetSessionChildren(server, ds);
15
+ }
@@ -0,0 +1,16 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import type { DataSource } from "../services/data-source";
3
+
4
+ export function registerListSessions(server: McpServer, ds: DataSource) {
5
+ server.tool(
6
+ "list_sessions",
7
+ "List all monitored AI coding sessions as a tree. Root sessions include a 'children' array of active sub-agent sessions.",
8
+ {},
9
+ async () => {
10
+ const sessions = await ds.fetchSessions();
11
+ return {
12
+ content: [{ type: "text" as const, text: JSON.stringify(sessions, null, 2) }],
13
+ };
14
+ },
15
+ );
16
+ }
@@ -0,0 +1,4 @@
1
+ declare module "neo-blessed" {
2
+ import blessed = require("blessed");
3
+ export = blessed;
4
+ }
@@ -0,0 +1,302 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * oc — OpenCode launcher / attach helper
4
+ *
5
+ * Usage:
6
+ * oc [args...] Auto-discover running instance → attach or launch
7
+ * oc [args...] All extra args are forwarded to the opencode command
8
+ *
9
+ * Environment variables:
10
+ * OPENCODE_LOCAL_PORT Force a specific port (skips discovery)
11
+ * OPENCODE_HOST Force a specific host (skips discovery, default 127.0.0.1)
12
+ *
13
+ * When both env vars are absent, oc discovers running opencode processes via
14
+ * process scanning (ps on Unix, wmic on Windows), probes their health endpoints,
15
+ * and either attaches to a live instance or launches a new one on port 4096.
16
+ *
17
+ * If multiple live instances are found an interactive numbered selector is shown.
18
+ */
19
+
20
+ const DEFAULT_PORT = 4096;
21
+ const DEFAULT_HOST = "127.0.0.1";
22
+ const HEALTH_TIMEOUT_MS = 800;
23
+
24
+ function printHelp() {
25
+ process.stdout.write(`oc
26
+
27
+ Usage:
28
+ oc [opencode args...]
29
+
30
+ Environment variables:
31
+ OPENCODE_LOCAL_PORT Force a specific port
32
+ OPENCODE_HOST Force a specific host
33
+
34
+ Behavior:
35
+ Auto-discovers running OpenCode instances and attaches when possible.
36
+ Launches a new instance on port 4096 when none is available.
37
+ `);
38
+ }
39
+
40
+ // ── Types ─────────────────────────────────────────────────────────────────────
41
+
42
+ interface Candidate {
43
+ url: string;
44
+ pid?: number;
45
+ }
46
+
47
+ // ── Process scanning ──────────────────────────────────────────────────────────
48
+
49
+ function scanProcessesUnix(): Candidate[] {
50
+ const proc = Bun.spawnSync(["ps", "-ax", "-o", "pid=,command="], {
51
+ stdout: "pipe",
52
+ stderr: "pipe",
53
+ });
54
+ if (proc.exitCode !== 0) return [];
55
+
56
+ const output = Buffer.from(proc.stdout).toString("utf8");
57
+ return parseProcessLines(output, /^\s*(\d+)\s+(.*)$/);
58
+ }
59
+
60
+ function scanProcessesWindows(): Candidate[] {
61
+ const proc = Bun.spawnSync(
62
+ ["wmic", "process", "get", "ProcessId,CommandLine", "/format:csv"],
63
+ { stdout: "pipe", stderr: "pipe" },
64
+ );
65
+ if (proc.exitCode !== 0) return [];
66
+
67
+ const output = Buffer.from(proc.stdout).toString("utf8");
68
+ const candidates: Candidate[] = [];
69
+
70
+ for (const line of output.split(/\r?\n/).filter(Boolean)) {
71
+ // CSV columns: Node,CommandLine,ProcessId
72
+ const cols = line.split(",");
73
+ if (cols.length < 3) continue;
74
+ const command = cols.slice(1, -1).join(","); // CommandLine may contain commas
75
+ const pidStr = cols[cols.length - 1] ?? "";
76
+ const pid = Number.parseInt(pidStr.trim(), 10);
77
+ if (Number.isNaN(pid)) continue;
78
+
79
+ const candidate = extractFromCommand(command, pid);
80
+ if (candidate) candidates.push(candidate);
81
+ }
82
+
83
+ return candidates;
84
+ }
85
+
86
+ function parseProcessLines(
87
+ output: string,
88
+ linePattern: RegExp,
89
+ ): Candidate[] {
90
+ const candidates: Candidate[] = [];
91
+
92
+ for (const line of output.split(/\r?\n/).filter(Boolean)) {
93
+ const match = line.match(linePattern);
94
+ if (!match) continue;
95
+ const pid = Number.parseInt(match[1] ?? "", 10);
96
+ const command = match[2] ?? "";
97
+ const candidate = extractFromCommand(command, pid);
98
+ if (candidate) candidates.push(candidate);
99
+ }
100
+
101
+ return candidates;
102
+ }
103
+
104
+ function extractFromCommand(command: string, pid: number): Candidate | null {
105
+ const isServe = /\bopencode\b.*\bserve\b/i.test(command);
106
+ const isTuiWithPort =
107
+ /\bopencode\b/i.test(command) &&
108
+ /--port[=\s]+([1-9]\d*)/.test(command) &&
109
+ !/\bserve\b/i.test(command);
110
+
111
+ if (!isServe && !isTuiWithPort) return null;
112
+
113
+ const portMatch = command.match(/--port[=\s]+(\d+)/);
114
+ const hostMatch = command.match(/--hostname[=\s]+(\S+)/);
115
+ const port = portMatch ? Number.parseInt(portMatch[1] ?? "", 10) : DEFAULT_PORT;
116
+ const rawHost = hostMatch ? (hostMatch[1] ?? DEFAULT_HOST) : DEFAULT_HOST;
117
+ // 0.0.0.0 listens on all interfaces — connect via loopback
118
+ const host = rawHost === "0.0.0.0" ? "127.0.0.1" : rawHost;
119
+
120
+ return { url: `http://${host}:${port}`, pid };
121
+ }
122
+
123
+ function discoverCandidates(): Candidate[] {
124
+ const raw =
125
+ process.platform === "win32" ? scanProcessesWindows() : scanProcessesUnix();
126
+
127
+ // Deduplicate by URL
128
+ const seen = new Set<string>();
129
+ const deduped = raw.filter((c) => {
130
+ if (seen.has(c.url)) return false;
131
+ seen.add(c.url);
132
+ return true;
133
+ });
134
+
135
+ // Always include the default endpoint as a fallback
136
+ if (!seen.has(`http://${DEFAULT_HOST}:${DEFAULT_PORT}`)) {
137
+ deduped.push({ url: `http://${DEFAULT_HOST}:${DEFAULT_PORT}` });
138
+ }
139
+
140
+ return deduped;
141
+ }
142
+
143
+ // ── Health probe ──────────────────────────────────────────────────────────────
144
+
145
+ async function isAlive(url: string): Promise<boolean> {
146
+ try {
147
+ const res = await fetch(`${url}/global/health`, {
148
+ signal: AbortSignal.timeout(HEALTH_TIMEOUT_MS),
149
+ });
150
+ return res.ok;
151
+ } catch {
152
+ return false;
153
+ }
154
+ }
155
+
156
+ async function filterAlive(candidates: Candidate[]): Promise<Candidate[]> {
157
+ const results = await Promise.all(
158
+ candidates.map(async (c) => ({ candidate: c, alive: await isAlive(c.url) })),
159
+ );
160
+ return results.filter((r) => r.alive).map((r) => r.candidate);
161
+ }
162
+
163
+ // ── Interactive selector ──────────────────────────────────────────────────────
164
+
165
+ async function selectInstance(candidates: Candidate[]): Promise<Candidate> {
166
+ const lines = [
167
+ `Found ${candidates.length} running OpenCode instances:`,
168
+ ...candidates.map(
169
+ (c, i) =>
170
+ ` [${i + 1}] ${c.url}${c.pid !== undefined ? ` (pid ${c.pid})` : ""}`,
171
+ ),
172
+ `Select [1-${candidates.length}]: `,
173
+ ];
174
+
175
+ process.stderr.write(lines.join("\n"));
176
+
177
+ // Read a line from stdin
178
+ for (let attempt = 0; attempt < 3; attempt++) {
179
+ const raw = await readLine();
180
+ const n = Number.parseInt(raw.trim(), 10);
181
+ if (!Number.isNaN(n) && n >= 1 && n <= candidates.length) {
182
+ return candidates[n - 1]!;
183
+ }
184
+ if (attempt < 2) {
185
+ process.stderr.write(`Invalid choice. Select [1-${candidates.length}]: `);
186
+ }
187
+ }
188
+
189
+ process.stderr.write("\nInvalid selection. Exiting.\n");
190
+ process.exit(1);
191
+ }
192
+
193
+ function readLine(): Promise<string> {
194
+ return new Promise((resolve) => {
195
+ let buffer = "";
196
+ process.stdin.setEncoding("utf8");
197
+ process.stdin.resume();
198
+ process.stdin.once("data", (chunk: string) => {
199
+ process.stdin.pause();
200
+ buffer += chunk;
201
+ resolve(buffer.split("\n")[0] ?? "");
202
+ });
203
+ });
204
+ }
205
+
206
+ // ── Spawn helpers ─────────────────────────────────────────────────────────────
207
+
208
+ async function run(cmd: string[]): Promise<void> {
209
+ const proc = Bun.spawn(cmd, {
210
+ stdio: ["inherit", "inherit", "inherit"],
211
+ });
212
+ const code = await proc.exited;
213
+ process.exit(code ?? 0);
214
+ }
215
+
216
+ function hasDirFlag(args: string[]): boolean {
217
+ return args.includes("--dir") || args.some((arg) => arg.startsWith("--dir="));
218
+ }
219
+
220
+ function hasProjectArg(args: string[]): boolean {
221
+ const first = args[0];
222
+ return first !== undefined && !first.startsWith("-");
223
+ }
224
+
225
+ export function buildAttachCommand(url: string, extraArgs: string[], cwd: string): string[] {
226
+ return hasDirFlag(extraArgs)
227
+ ? ["opencode", "attach", url, ...extraArgs]
228
+ : ["opencode", "attach", url, "--dir", cwd, ...extraArgs];
229
+ }
230
+
231
+ export function buildLaunchCommand(extraArgs: string[], cwd: string, port?: string, host?: string): string[] {
232
+ const args = hasProjectArg(extraArgs) ? [...extraArgs] : [cwd, ...extraArgs];
233
+ const command = ["opencode", ...args];
234
+ if (port !== undefined) command.push("--port", port);
235
+ if (host !== undefined) command.push("--hostname", host);
236
+ return command;
237
+ }
238
+
239
+ // ── Main ──────────────────────────────────────────────────────────────────────
240
+
241
+ async function main(): Promise<void> {
242
+ const extraArgs = process.argv.slice(2);
243
+ if (extraArgs.includes("--help") || extraArgs.includes("-h")) {
244
+ printHelp();
245
+ process.exit(0);
246
+ }
247
+ const cwd = process.cwd();
248
+
249
+ const envPort = process.env["OPENCODE_LOCAL_PORT"];
250
+ const envHost = process.env["OPENCODE_HOST"];
251
+
252
+ // ── Explicit mode: both env vars set ───────────────────────────────────────
253
+ if (envPort !== undefined && envHost !== undefined) {
254
+ const url = `http://${envHost}:${envPort}`;
255
+ const alive = await isAlive(url);
256
+ if (alive) {
257
+ await run(buildAttachCommand(url, extraArgs, cwd));
258
+ } else {
259
+ await run(buildLaunchCommand(extraArgs, cwd, envPort, envHost));
260
+ }
261
+ return;
262
+ }
263
+
264
+ // Partial override: only port or only host specified
265
+ if (envPort !== undefined || envHost !== undefined) {
266
+ const port = envPort ?? String(DEFAULT_PORT);
267
+ const host = envHost ?? DEFAULT_HOST;
268
+ const url = `http://${host}:${port}`;
269
+ const alive = await isAlive(url);
270
+ if (alive) {
271
+ await run(buildAttachCommand(url, extraArgs, cwd));
272
+ } else {
273
+ await run(buildLaunchCommand(extraArgs, cwd, port, host));
274
+ }
275
+ return;
276
+ }
277
+
278
+ // ── Auto-discovery mode ────────────────────────────────────────────────────
279
+ const candidates = discoverCandidates();
280
+ const alive = await filterAlive(candidates);
281
+
282
+ if (alive.length === 0) {
283
+ // Nothing running — launch on default port
284
+ await run(buildLaunchCommand(extraArgs, cwd, String(DEFAULT_PORT)));
285
+ return;
286
+ }
287
+
288
+ if (alive.length === 1) {
289
+ await run(buildAttachCommand(alive[0]!.url, extraArgs, cwd));
290
+ return;
291
+ }
292
+
293
+ // Multiple instances — show selector
294
+ const chosen = await selectInstance(alive);
295
+ await run(buildAttachCommand(chosen.url, extraArgs, cwd));
296
+ }
297
+
298
+ if (import.meta.main) {
299
+ await main();
300
+ }
301
+
302
+ export {};