@colinmollenhour/occtl 1.0.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 (53) hide show
  1. package/README.md +290 -0
  2. package/SKILL.md +692 -0
  3. package/dist/client.d.ts +4 -0
  4. package/dist/client.js +64 -0
  5. package/dist/commands/session-abort.d.ts +2 -0
  6. package/dist/commands/session-abort.js +16 -0
  7. package/dist/commands/session-children.d.ts +2 -0
  8. package/dist/commands/session-children.js +30 -0
  9. package/dist/commands/session-create.d.ts +2 -0
  10. package/dist/commands/session-create.js +39 -0
  11. package/dist/commands/session-diff.d.ts +2 -0
  12. package/dist/commands/session-diff.js +35 -0
  13. package/dist/commands/session-get.d.ts +2 -0
  14. package/dist/commands/session-get.js +24 -0
  15. package/dist/commands/session-last.d.ts +2 -0
  16. package/dist/commands/session-last.js +39 -0
  17. package/dist/commands/session-list.d.ts +2 -0
  18. package/dist/commands/session-list.js +91 -0
  19. package/dist/commands/session-messages.d.ts +2 -0
  20. package/dist/commands/session-messages.js +44 -0
  21. package/dist/commands/session-respond.d.ts +2 -0
  22. package/dist/commands/session-respond.js +78 -0
  23. package/dist/commands/session-send.d.ts +2 -0
  24. package/dist/commands/session-send.js +114 -0
  25. package/dist/commands/session-share.d.ts +3 -0
  26. package/dist/commands/session-share.js +53 -0
  27. package/dist/commands/session-status.d.ts +2 -0
  28. package/dist/commands/session-status.js +45 -0
  29. package/dist/commands/session-summary.d.ts +2 -0
  30. package/dist/commands/session-summary.js +87 -0
  31. package/dist/commands/session-todo.d.ts +2 -0
  32. package/dist/commands/session-todo.js +41 -0
  33. package/dist/commands/session-wait-for-text.d.ts +2 -0
  34. package/dist/commands/session-wait-for-text.js +119 -0
  35. package/dist/commands/session-wait.d.ts +4 -0
  36. package/dist/commands/session-wait.js +85 -0
  37. package/dist/commands/session-watch.d.ts +2 -0
  38. package/dist/commands/session-watch.js +101 -0
  39. package/dist/commands/skill.d.ts +3 -0
  40. package/dist/commands/skill.js +55 -0
  41. package/dist/commands/worktree.d.ts +5 -0
  42. package/dist/commands/worktree.js +359 -0
  43. package/dist/format.d.ts +19 -0
  44. package/dist/format.js +115 -0
  45. package/dist/index.d.ts +2 -0
  46. package/dist/index.js +63 -0
  47. package/dist/resolve.d.ts +6 -0
  48. package/dist/resolve.js +47 -0
  49. package/dist/sse.d.ts +40 -0
  50. package/dist/sse.js +128 -0
  51. package/dist/wait-util.d.ts +23 -0
  52. package/dist/wait-util.js +118 -0
  53. package/package.json +49 -0
package/dist/sse.js ADDED
@@ -0,0 +1,128 @@
1
+ import { getBaseUrl } from "./client.js";
2
+ /**
3
+ * Extract the session ID from an event, checking all known locations.
4
+ */
5
+ export function getEventSessionId(event) {
6
+ const props = event.properties;
7
+ const sid = props.sessionID ??
8
+ props.info?.sessionID ??
9
+ props.info?.id ??
10
+ props.part?.sessionID ??
11
+ undefined;
12
+ return sid || undefined; // treat empty string as undefined
13
+ }
14
+ /**
15
+ * Check if an SSE event belongs to a given session.
16
+ */
17
+ export function isSessionEvent(event, sessionId) {
18
+ return getEventSessionId(event) === sessionId;
19
+ }
20
+ /**
21
+ * Connect to the OpenCode SSE event stream and invoke a callback for each
22
+ * parsed event that matches the given session.
23
+ *
24
+ * Returns "stopped" if the callback returned "stop", or "disconnected" if
25
+ * the stream ended unexpectedly.
26
+ */
27
+ export async function streamEvents(sessionId, onEvent) {
28
+ return streamAllEvents((event) => {
29
+ if (!isSessionEvent(event, sessionId))
30
+ return;
31
+ return onEvent(event);
32
+ });
33
+ }
34
+ /**
35
+ * Connect to the SSE stream and return a handle with cancel + connected signal.
36
+ * This is the low-level version for callers that need to coordinate startup.
37
+ */
38
+ export function startStream(sessionId, onEvent) {
39
+ return startAllStream((event) => {
40
+ if (!isSessionEvent(event, sessionId))
41
+ return;
42
+ return onEvent(event);
43
+ });
44
+ }
45
+ /**
46
+ * Start an SSE stream (unfiltered) and return a handle with cancel + connected signal.
47
+ */
48
+ export function startAllStream(onEvent) {
49
+ let cancelFn = () => { };
50
+ let resolveConnected;
51
+ const connected = new Promise((r) => {
52
+ resolveConnected = r;
53
+ });
54
+ const result = (async () => {
55
+ const url = `${getBaseUrl()}/event`;
56
+ let response;
57
+ try {
58
+ response = await fetch(url, {
59
+ headers: { Accept: "text/event-stream" },
60
+ });
61
+ }
62
+ catch {
63
+ resolveConnected();
64
+ return "disconnected";
65
+ }
66
+ if (!response.ok || !response.body) {
67
+ resolveConnected();
68
+ return "disconnected";
69
+ }
70
+ const reader = response.body.getReader();
71
+ cancelFn = () => reader.cancel().catch(() => { });
72
+ // Signal that the connection is established
73
+ resolveConnected();
74
+ const decoder = new TextDecoder();
75
+ let buffer = "";
76
+ let stoppedCleanly = false;
77
+ try {
78
+ while (true) {
79
+ const { done, value } = await reader.read();
80
+ if (done)
81
+ break;
82
+ buffer += decoder.decode(value, { stream: true });
83
+ const lines = buffer.split("\n");
84
+ buffer = lines.pop() || "";
85
+ for (const line of lines) {
86
+ if (!line.startsWith("data: "))
87
+ continue;
88
+ const data = line.slice(6);
89
+ if (data === "[DONE]")
90
+ continue;
91
+ let event;
92
+ try {
93
+ event = JSON.parse(data);
94
+ }
95
+ catch {
96
+ // Skip unparseable SSE data
97
+ continue;
98
+ }
99
+ try {
100
+ const cbResult = await onEvent(event);
101
+ if (cbResult === "stop") {
102
+ stoppedCleanly = true;
103
+ reader.cancel().catch(() => { });
104
+ return "stopped";
105
+ }
106
+ }
107
+ catch (err) {
108
+ // Log callback errors to stderr instead of swallowing
109
+ console.error(`[occtl] SSE callback error: ${err instanceof Error ? err.message : String(err)}`);
110
+ }
111
+ }
112
+ }
113
+ }
114
+ finally {
115
+ reader.cancel().catch(() => { });
116
+ }
117
+ return stoppedCleanly ? "stopped" : "disconnected";
118
+ })();
119
+ return { result, cancel: () => cancelFn(), connected };
120
+ }
121
+ /**
122
+ * Connect to the SSE stream and invoke callback for ALL events (unfiltered).
123
+ * Returns "stopped" or "disconnected".
124
+ */
125
+ export async function streamAllEvents(onEvent) {
126
+ const handle = startAllStream(onEvent);
127
+ return handle.result;
128
+ }
@@ -0,0 +1,23 @@
1
+ import type { OpencodeClient } from "@opencode-ai/sdk";
2
+ export interface WaitResult {
3
+ /** Whether the session is confirmed idle. */
4
+ idle: boolean;
5
+ /** How it was determined: "api" (status check), "sse" (event), "disconnected" (stream lost). */
6
+ reason: "api" | "sse" | "disconnected" | "timeout";
7
+ }
8
+ /**
9
+ * Race-safe wait for a session to go idle. Starts the SSE stream, waits for
10
+ * the connection to establish, then checks the API. This eliminates the gap
11
+ * where the session could go idle between the check and the stream starting.
12
+ */
13
+ export declare function waitForIdle(client: OpencodeClient, sessionId: string, timeoutMs?: number): Promise<WaitResult>;
14
+ export interface WaitAnyResult {
15
+ /** The session ID that went idle first (empty string if timeout/disconnect). */
16
+ sessionID: string;
17
+ /** How it was determined. */
18
+ reason: "api" | "sse" | "disconnected" | "timeout";
19
+ }
20
+ /**
21
+ * Race-safe wait for any of multiple sessions to go idle.
22
+ */
23
+ export declare function waitForAnyIdle(client: OpencodeClient, sessionIds: string[], timeoutMs?: number): Promise<WaitAnyResult>;
@@ -0,0 +1,118 @@
1
+ import { startStream, startAllStream, getEventSessionId } from "./sse.js";
2
+ /**
3
+ * Race-safe wait for a session to go idle. Starts the SSE stream, waits for
4
+ * the connection to establish, then checks the API. This eliminates the gap
5
+ * where the session could go idle between the check and the stream starting.
6
+ */
7
+ export async function waitForIdle(client, sessionId, timeoutMs) {
8
+ return new Promise((resolve) => {
9
+ let settled = false;
10
+ const settle = (result) => {
11
+ if (settled)
12
+ return;
13
+ settled = true;
14
+ if (timer)
15
+ clearTimeout(timer);
16
+ handle.cancel();
17
+ resolve(result);
18
+ };
19
+ // Timeout
20
+ let timer;
21
+ if (timeoutMs && timeoutMs > 0) {
22
+ timer = setTimeout(() => {
23
+ settle({ idle: false, reason: "timeout" });
24
+ }, timeoutMs);
25
+ }
26
+ // Start SSE stream
27
+ const handle = startStream(sessionId, (event) => {
28
+ if (event.type === "session.idle") {
29
+ settle({ idle: true, reason: "sse" });
30
+ return "stop";
31
+ }
32
+ });
33
+ // When stream ends unexpectedly
34
+ handle.result.then((streamResult) => {
35
+ if (!settled && streamResult === "disconnected") {
36
+ settle({ idle: false, reason: "disconnected" });
37
+ }
38
+ });
39
+ // After SSE is connected, check current status via API
40
+ handle.connected.then(async () => {
41
+ if (settled)
42
+ return;
43
+ try {
44
+ const statusResult = await client.session.status();
45
+ const statuses = statusResult.data ?? {};
46
+ const current = statuses[sessionId];
47
+ if (!current || current.type === "idle") {
48
+ settle({ idle: true, reason: "api" });
49
+ }
50
+ }
51
+ catch {
52
+ // API check failed; rely on SSE stream
53
+ }
54
+ });
55
+ });
56
+ }
57
+ /**
58
+ * Race-safe wait for any of multiple sessions to go idle.
59
+ */
60
+ export async function waitForAnyIdle(client, sessionIds, timeoutMs) {
61
+ const watchSet = new Set(sessionIds);
62
+ return new Promise((resolve) => {
63
+ let settled = false;
64
+ const settle = (result) => {
65
+ if (settled)
66
+ return;
67
+ settled = true;
68
+ if (timer)
69
+ clearTimeout(timer);
70
+ handle.cancel();
71
+ resolve(result);
72
+ };
73
+ // Timeout
74
+ let timer;
75
+ if (timeoutMs && timeoutMs > 0) {
76
+ timer = setTimeout(() => {
77
+ settle({ sessionID: "", reason: "timeout" });
78
+ }, timeoutMs);
79
+ }
80
+ // Start SSE stream (unfiltered, watching multiple sessions)
81
+ const handle = startAllStream((event) => {
82
+ if (event.type !== "session.idle")
83
+ return;
84
+ const sid = getEventSessionId(event);
85
+ if (!sid || !watchSet.has(sid))
86
+ return;
87
+ settle({ sessionID: sid, reason: "sse" });
88
+ return "stop";
89
+ });
90
+ // When stream ends unexpectedly
91
+ handle.result.then((streamResult) => {
92
+ if (!settled && streamResult === "disconnected") {
93
+ settle({ sessionID: "", reason: "disconnected" });
94
+ }
95
+ });
96
+ // After SSE is connected, check current status via API
97
+ handle.connected.then(async () => {
98
+ if (settled)
99
+ return;
100
+ try {
101
+ const statusResult = await client.session.status();
102
+ const statuses = statusResult.data ?? {};
103
+ for (const sid of sessionIds) {
104
+ if (settled)
105
+ return;
106
+ const current = statuses[sid];
107
+ if (!current || current.type === "idle") {
108
+ settle({ sessionID: sid, reason: "api" });
109
+ return;
110
+ }
111
+ }
112
+ }
113
+ catch {
114
+ // API check failed; rely on SSE stream
115
+ }
116
+ });
117
+ });
118
+ }
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@colinmollenhour/occtl",
3
+ "version": "1.0.0",
4
+ "description": "Extended CLI for managing OpenCode sessions",
5
+ "keywords": [
6
+ "opencode",
7
+ "cli",
8
+ "session",
9
+ "automation",
10
+ "ralph-loop",
11
+ "ai-agent",
12
+ "orchestration"
13
+ ],
14
+ "homepage": "https://github.com/colinmollenhour/occtl#readme",
15
+ "bugs": {
16
+ "url": "https://github.com/colinmollenhour/occtl/issues"
17
+ },
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/colinmollenhour/occtl.git"
21
+ },
22
+ "license": "MIT",
23
+ "author": "Colin Mollenhour",
24
+ "type": "module",
25
+ "main": "index.js",
26
+ "bin": {
27
+ "occtl": "dist/index.js"
28
+ },
29
+ "files": [
30
+ "dist",
31
+ "SKILL.md",
32
+ "README.md"
33
+ ],
34
+ "scripts": {
35
+ "build": "tsc",
36
+ "prepublishOnly": "tsc",
37
+ "dev": "tsx src/index.ts",
38
+ "test": "echo \"Error: no test specified\" && exit 1"
39
+ },
40
+ "dependencies": {
41
+ "@opencode-ai/sdk": "^1.3.0",
42
+ "commander": "^14.0.0"
43
+ },
44
+ "devDependencies": {
45
+ "@types/node": "^25.5.0",
46
+ "tsx": "^4.21.0",
47
+ "typescript": "^6.0.2"
48
+ }
49
+ }