@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.
- package/README.md +290 -0
- package/SKILL.md +692 -0
- package/dist/client.d.ts +4 -0
- package/dist/client.js +64 -0
- package/dist/commands/session-abort.d.ts +2 -0
- package/dist/commands/session-abort.js +16 -0
- package/dist/commands/session-children.d.ts +2 -0
- package/dist/commands/session-children.js +30 -0
- package/dist/commands/session-create.d.ts +2 -0
- package/dist/commands/session-create.js +39 -0
- package/dist/commands/session-diff.d.ts +2 -0
- package/dist/commands/session-diff.js +35 -0
- package/dist/commands/session-get.d.ts +2 -0
- package/dist/commands/session-get.js +24 -0
- package/dist/commands/session-last.d.ts +2 -0
- package/dist/commands/session-last.js +39 -0
- package/dist/commands/session-list.d.ts +2 -0
- package/dist/commands/session-list.js +91 -0
- package/dist/commands/session-messages.d.ts +2 -0
- package/dist/commands/session-messages.js +44 -0
- package/dist/commands/session-respond.d.ts +2 -0
- package/dist/commands/session-respond.js +78 -0
- package/dist/commands/session-send.d.ts +2 -0
- package/dist/commands/session-send.js +114 -0
- package/dist/commands/session-share.d.ts +3 -0
- package/dist/commands/session-share.js +53 -0
- package/dist/commands/session-status.d.ts +2 -0
- package/dist/commands/session-status.js +45 -0
- package/dist/commands/session-summary.d.ts +2 -0
- package/dist/commands/session-summary.js +87 -0
- package/dist/commands/session-todo.d.ts +2 -0
- package/dist/commands/session-todo.js +41 -0
- package/dist/commands/session-wait-for-text.d.ts +2 -0
- package/dist/commands/session-wait-for-text.js +119 -0
- package/dist/commands/session-wait.d.ts +4 -0
- package/dist/commands/session-wait.js +85 -0
- package/dist/commands/session-watch.d.ts +2 -0
- package/dist/commands/session-watch.js +101 -0
- package/dist/commands/skill.d.ts +3 -0
- package/dist/commands/skill.js +55 -0
- package/dist/commands/worktree.d.ts +5 -0
- package/dist/commands/worktree.js +359 -0
- package/dist/format.d.ts +19 -0
- package/dist/format.js +115 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +63 -0
- package/dist/resolve.d.ts +6 -0
- package/dist/resolve.js +47 -0
- package/dist/sse.d.ts +40 -0
- package/dist/sse.js +128 -0
- package/dist/wait-util.d.ts +23 -0
- package/dist/wait-util.js +118 -0
- 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
|
+
}
|