opencode-courier 0.1.6
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 +677 -0
- package/dist/cleanup.d.ts +59 -0
- package/dist/cleanup.js +98 -0
- package/dist/courier.d.ts +97 -0
- package/dist/courier.js +143 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.js +422 -0
- package/dist/later.d.ts +40 -0
- package/dist/later.js +85 -0
- package/dist/question.d.ts +138 -0
- package/dist/question.js +620 -0
- package/dist/relay.d.ts +76 -0
- package/dist/relay.js +118 -0
- package/dist/roster.d.ts +51 -0
- package/dist/roster.js +83 -0
- package/dist/storage.d.ts +6 -0
- package/dist/storage.js +12 -0
- package/dist/watch.d.ts +69 -0
- package/dist/watch.js +167 -0
- package/dist/webhook.d.ts +103 -0
- package/dist/webhook.js +396 -0
- package/package.json +46 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { Plugin } from "@opencode-ai/plugin";
|
|
2
|
+
import { type RosterStorage } from "./roster.js";
|
|
3
|
+
type Context = Plugin.Context;
|
|
4
|
+
/** What would be lost by removing a worktree. */
|
|
5
|
+
export interface WorktreeState {
|
|
6
|
+
/** Paths with uncommitted changes, untracked files included, as `git status --porcelain` lists them. */
|
|
7
|
+
readonly changes: readonly string[];
|
|
8
|
+
/**
|
|
9
|
+
* Commits reachable from the worktree's HEAD but from no branch, tag or remote-tracking ref, nor
|
|
10
|
+
* from the commit the worktree was made from, newest first.
|
|
11
|
+
*/
|
|
12
|
+
readonly commits: readonly string[];
|
|
13
|
+
}
|
|
14
|
+
export interface CleanupPorts {
|
|
15
|
+
readonly storage: RosterStorage;
|
|
16
|
+
readonly worktree: Pick<Context["worktree"], "remove">;
|
|
17
|
+
/** The plugin's own location, for roster entries recorded before they carried their source. */
|
|
18
|
+
readonly directory: string;
|
|
19
|
+
/** The worktree's state, or undefined when its directory no longer exists; `base` is the commit it was made from. */
|
|
20
|
+
readonly inspect: (directory: string, base?: string) => Promise<WorktreeState | undefined>;
|
|
21
|
+
}
|
|
22
|
+
export interface CleanupInput {
|
|
23
|
+
readonly sessionID: string;
|
|
24
|
+
readonly force?: boolean;
|
|
25
|
+
}
|
|
26
|
+
export type CleanupResult = {
|
|
27
|
+
readonly sessionID: string;
|
|
28
|
+
readonly directory: string;
|
|
29
|
+
readonly outcome: "removed";
|
|
30
|
+
} | {
|
|
31
|
+
readonly sessionID: string;
|
|
32
|
+
readonly directory: string;
|
|
33
|
+
readonly outcome: "gone";
|
|
34
|
+
} | {
|
|
35
|
+
readonly sessionID: string;
|
|
36
|
+
readonly directory: string;
|
|
37
|
+
readonly outcome: "kept";
|
|
38
|
+
readonly reason: string;
|
|
39
|
+
readonly changes: readonly string[];
|
|
40
|
+
readonly commits: readonly string[];
|
|
41
|
+
};
|
|
42
|
+
/** Why a worktree in this state must be kept, or undefined when removing it loses nothing. */
|
|
43
|
+
export declare function keepReason(state: WorktreeState): string | undefined;
|
|
44
|
+
/** At most this many changes and commits are returned; the reason still counts them all. */
|
|
45
|
+
export declare const MAX_LISTED = 50;
|
|
46
|
+
/**
|
|
47
|
+
* Removes the worktree of an isolated child the parent started, and forgets the child. A worktree
|
|
48
|
+
* with uncommitted changes or commits that exist nowhere else is kept unless `force` is set, and the
|
|
49
|
+
* result says what is in it.
|
|
50
|
+
*/
|
|
51
|
+
export declare function cleanup(ports: CleanupPorts, parentID: string, input: CleanupInput): Promise<CleanupResult>;
|
|
52
|
+
/** The commit a worktree is on, or undefined when git cannot tell. */
|
|
53
|
+
export declare function headOf(directory: string): Promise<string | undefined>;
|
|
54
|
+
/**
|
|
55
|
+
* Reads a worktree's state with git; undefined when the directory is gone. Commits reachable from
|
|
56
|
+
* `base`, the commit the worktree was made from, are not its own work and are not listed.
|
|
57
|
+
*/
|
|
58
|
+
export declare function inspectWorktree(directory: string, base?: string): Promise<WorktreeState | undefined>;
|
|
59
|
+
export {};
|
package/dist/cleanup.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { rosterKey } from "./roster.js";
|
|
4
|
+
/** Why a worktree in this state must be kept, or undefined when removing it loses nothing. */
|
|
5
|
+
export function keepReason(state) {
|
|
6
|
+
const reasons = [
|
|
7
|
+
state.changes.length ? `${count(state.changes.length, "uncommitted change")} (${preview(state.changes)})` : "",
|
|
8
|
+
state.commits.length
|
|
9
|
+
? `${count(state.commits.length, "commit")} on no branch, tag or remote (${preview(state.commits)})`
|
|
10
|
+
: "",
|
|
11
|
+
].filter(Boolean);
|
|
12
|
+
return reasons.length ? reasons.join(" and ") : undefined;
|
|
13
|
+
}
|
|
14
|
+
function count(n, noun) {
|
|
15
|
+
return `${n} ${noun}${n === 1 ? "" : "s"}`;
|
|
16
|
+
}
|
|
17
|
+
/** At most this many changes and commits are returned; the reason still counts them all. */
|
|
18
|
+
export const MAX_LISTED = 50;
|
|
19
|
+
function preview(items) {
|
|
20
|
+
return items.length > 5 ? `${items.slice(0, 5).join(", ")}, ...` : items.join(", ");
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Removes the worktree of an isolated child the parent started, and forgets the child. A worktree
|
|
24
|
+
* with uncommitted changes or commits that exist nowhere else is kept unless `force` is set, and the
|
|
25
|
+
* result says what is in it.
|
|
26
|
+
*/
|
|
27
|
+
export async function cleanup(ports, parentID, input) {
|
|
28
|
+
const entry = (await ports.storage.get(rosterKey(parentID, input.sessionID)));
|
|
29
|
+
if (!entry)
|
|
30
|
+
throw new Error(`${input.sessionID} is not on the courier_children list of ${parentID}.`);
|
|
31
|
+
if (!entry.isolated)
|
|
32
|
+
throw new Error(`${input.sessionID} ran in ${entry.directory}, not in a worktree of its own; there is nothing to remove.`);
|
|
33
|
+
const { directory } = entry;
|
|
34
|
+
const forget = () => ports.storage.remove(rosterKey(parentID, input.sessionID));
|
|
35
|
+
// With force the state only decides whether there is anything left to remove, so a worktree git
|
|
36
|
+
// can no longer read is still removed.
|
|
37
|
+
const state = await ports
|
|
38
|
+
.inspect(directory, entry.base)
|
|
39
|
+
.catch((error) => (input.force ? { changes: [], commits: [] } : Promise.reject(error)));
|
|
40
|
+
if (!state) {
|
|
41
|
+
await forget();
|
|
42
|
+
return { sessionID: input.sessionID, directory, outcome: "gone" };
|
|
43
|
+
}
|
|
44
|
+
const reason = keepReason(state);
|
|
45
|
+
if (reason && !input.force)
|
|
46
|
+
return {
|
|
47
|
+
sessionID: input.sessionID,
|
|
48
|
+
directory,
|
|
49
|
+
outcome: "kept",
|
|
50
|
+
reason,
|
|
51
|
+
changes: state.changes.slice(0, MAX_LISTED),
|
|
52
|
+
commits: state.commits.slice(0, MAX_LISTED),
|
|
53
|
+
};
|
|
54
|
+
await ports.worktree.remove({
|
|
55
|
+
location: { directory: entry.source ?? ports.directory },
|
|
56
|
+
directory,
|
|
57
|
+
force: input.force === true,
|
|
58
|
+
});
|
|
59
|
+
await forget();
|
|
60
|
+
return { sessionID: input.sessionID, directory, outcome: "removed" };
|
|
61
|
+
}
|
|
62
|
+
function git(directory, args) {
|
|
63
|
+
return new Promise((resolve, reject) => execFile("git", ["-C", directory, ...args], { maxBuffer: 16 * 1024 * 1024 }, (error, stdout, stderr) => error ? reject(new Error(`git ${args[0]} in ${directory}: ${stderr.trim() || error.message}`)) : resolve(stdout)));
|
|
64
|
+
}
|
|
65
|
+
/** The commit a worktree is on, or undefined when git cannot tell. */
|
|
66
|
+
export function headOf(directory) {
|
|
67
|
+
return git(directory, ["rev-parse", "HEAD"]).then((out) => out.trim() || undefined, () => undefined);
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Reads a worktree's state with git; undefined when the directory is gone. Commits reachable from
|
|
71
|
+
* `base`, the commit the worktree was made from, are not its own work and are not listed.
|
|
72
|
+
*/
|
|
73
|
+
export async function inspectWorktree(directory, base) {
|
|
74
|
+
if (!existsSync(directory))
|
|
75
|
+
return undefined;
|
|
76
|
+
// An untracked directory is one entry, not every file in it.
|
|
77
|
+
const status = await git(directory, ["status", "--porcelain=v1", "-z", "--untracked-files=normal"]);
|
|
78
|
+
const changes = [];
|
|
79
|
+
const records = status.split("\0").filter(Boolean);
|
|
80
|
+
for (let i = 0; i < records.length; i++) {
|
|
81
|
+
const record = records[i];
|
|
82
|
+
changes.push(record.slice(3));
|
|
83
|
+
// A rename or copy is followed by its source path in a record of its own.
|
|
84
|
+
if ("RC".includes(record[0]) || "RC".includes(record[1]))
|
|
85
|
+
i++;
|
|
86
|
+
}
|
|
87
|
+
const log = await git(directory, [
|
|
88
|
+
"log",
|
|
89
|
+
"--format=%h %s",
|
|
90
|
+
"HEAD",
|
|
91
|
+
"--not",
|
|
92
|
+
"--branches",
|
|
93
|
+
"--tags",
|
|
94
|
+
"--remotes",
|
|
95
|
+
...(base ? [base] : []),
|
|
96
|
+
]);
|
|
97
|
+
return { changes, commits: log.split("\n").filter(Boolean) };
|
|
98
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { Plugin } from "@opencode-ai/plugin";
|
|
2
|
+
import type { Prompt } from "./question.js";
|
|
3
|
+
import { type RosterStorage } from "./roster.js";
|
|
4
|
+
type Context = Plugin.Context;
|
|
5
|
+
/** The slice of the plugin context the courier tools use; tests pass a fake. */
|
|
6
|
+
export interface CourierPorts {
|
|
7
|
+
readonly session: Pick<Context["session"], "create" | "prompt" | "synthetic" | "get" | "context">;
|
|
8
|
+
readonly agent: Pick<Context["agent"], "get">;
|
|
9
|
+
readonly worktree: Pick<Context["worktree"], "create" | "remove">;
|
|
10
|
+
/** The commit a directory's checkout is on, or undefined; recorded as an isolated child's base. */
|
|
11
|
+
readonly head: (directory: string) => Promise<string | undefined>;
|
|
12
|
+
readonly storage: RosterStorage;
|
|
13
|
+
readonly directory: string;
|
|
14
|
+
readonly now: () => number;
|
|
15
|
+
/** The permission requests and questions a session waits on, wherever they are pending. */
|
|
16
|
+
readonly pending: (sessionID: string) => Promise<ReadonlyArray<Pending>>;
|
|
17
|
+
}
|
|
18
|
+
/** A request a session waits on until someone answers it: a permission request, or a question it asked. */
|
|
19
|
+
export type Pending = {
|
|
20
|
+
readonly type: "permission";
|
|
21
|
+
readonly requestID: string;
|
|
22
|
+
readonly action: string;
|
|
23
|
+
readonly resources: ReadonlyArray<string>;
|
|
24
|
+
readonly save?: ReadonlyArray<string>;
|
|
25
|
+
} | {
|
|
26
|
+
readonly type: "question";
|
|
27
|
+
readonly requestID: string;
|
|
28
|
+
readonly questions: ReadonlyArray<Prompt>;
|
|
29
|
+
/** Its question call was cut off; the answer reaches it as a message. */
|
|
30
|
+
readonly stopped?: true;
|
|
31
|
+
};
|
|
32
|
+
export interface SpawnInput {
|
|
33
|
+
readonly task: string;
|
|
34
|
+
readonly title?: string;
|
|
35
|
+
readonly agent?: string;
|
|
36
|
+
readonly isolate?: boolean;
|
|
37
|
+
}
|
|
38
|
+
export interface SendInput {
|
|
39
|
+
readonly sessionID: string;
|
|
40
|
+
readonly message: string;
|
|
41
|
+
readonly queue?: boolean;
|
|
42
|
+
}
|
|
43
|
+
export interface StatusInput {
|
|
44
|
+
readonly sessionID: string;
|
|
45
|
+
}
|
|
46
|
+
export interface ChildrenInput {
|
|
47
|
+
readonly sessionID?: string;
|
|
48
|
+
}
|
|
49
|
+
/** Closes a tool result after which the caller most likely has nothing left to do. */
|
|
50
|
+
export declare const END_TURN = "If nothing else is left to do now, end your turn by replying without calling more tools.";
|
|
51
|
+
export declare function childBrief(parentID: string, task: string): string;
|
|
52
|
+
export declare function envelope(from: string, message: string, attributes?: Record<string, string>): string;
|
|
53
|
+
/** Creates a child session, hands it the task and returns at once; the child reports back with courier_send. */
|
|
54
|
+
export declare function spawn(ports: CourierPorts, parentID: string, input: SpawnInput): Promise<{
|
|
55
|
+
rosterError?: string | undefined;
|
|
56
|
+
sessionID: string;
|
|
57
|
+
directory: string;
|
|
58
|
+
}>;
|
|
59
|
+
/** Drops a message into another session's inbox; OpenCode wakes that session if it is idle. */
|
|
60
|
+
export declare function send(ports: CourierPorts, from: string, input: SendInput): Promise<{
|
|
61
|
+
messageID: string;
|
|
62
|
+
}>;
|
|
63
|
+
/** A one-off look at a session, for check-ins; not meant to be called in a loop. */
|
|
64
|
+
export declare function status(ports: CourierPorts, input: StatusInput): Promise<Partial<{
|
|
65
|
+
sessionID: string;
|
|
66
|
+
title: string | undefined;
|
|
67
|
+
parentID: string | undefined;
|
|
68
|
+
outcome: "failed" | "succeeded" | "interrupted" | undefined;
|
|
69
|
+
updated: number;
|
|
70
|
+
idle: number | undefined;
|
|
71
|
+
lastText: string | undefined;
|
|
72
|
+
pending: readonly Pending[] | undefined;
|
|
73
|
+
}>>;
|
|
74
|
+
/** The sessions a parent started, each with what courier_status reports, or the error it gave. */
|
|
75
|
+
export declare function listChildren(ports: CourierPorts, parentID: string): Promise<({
|
|
76
|
+
directory: string;
|
|
77
|
+
isolated: boolean;
|
|
78
|
+
created: number;
|
|
79
|
+
sessionID?: string | undefined;
|
|
80
|
+
title?: string | undefined;
|
|
81
|
+
parentID?: string | undefined;
|
|
82
|
+
outcome?: "failed" | "succeeded" | "interrupted" | undefined;
|
|
83
|
+
updated?: number | undefined;
|
|
84
|
+
idle?: number | undefined;
|
|
85
|
+
lastText?: string | undefined;
|
|
86
|
+
pending?: readonly Pending[] | undefined;
|
|
87
|
+
} | {
|
|
88
|
+
error: string;
|
|
89
|
+
directory: string;
|
|
90
|
+
isolated: boolean;
|
|
91
|
+
created: number;
|
|
92
|
+
sessionID: string;
|
|
93
|
+
title: string;
|
|
94
|
+
})[]>;
|
|
95
|
+
/** A readable message for a failed courier call; OpenCode's own errors can carry an empty message. */
|
|
96
|
+
export declare function describeFailure(tool: string, error: unknown): Error;
|
|
97
|
+
export {};
|
package/dist/courier.js
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { current, record } from "./roster.js";
|
|
2
|
+
/** Closes a tool result after which the caller most likely has nothing left to do. */
|
|
3
|
+
export const END_TURN = "If nothing else is left to do now, end your turn by replying without calling more tools.";
|
|
4
|
+
export function childBrief(parentID, task) {
|
|
5
|
+
return [
|
|
6
|
+
`You were started by session ${parentID} through opencode-courier.`,
|
|
7
|
+
"",
|
|
8
|
+
`When you finish, or need a decision you cannot make yourself, call courier_send with sessionID "${parentID}" and a short report.`,
|
|
9
|
+
"That message wakes the parent. It is the only way the parent hears from you, so do not end without sending it.",
|
|
10
|
+
"If you need the person to decide something, use your question tool; it reaches them through the session that started you.",
|
|
11
|
+
"",
|
|
12
|
+
"Task:",
|
|
13
|
+
task,
|
|
14
|
+
].join("\n");
|
|
15
|
+
}
|
|
16
|
+
export function envelope(from, message, attributes = {}) {
|
|
17
|
+
const extra = Object.entries(attributes)
|
|
18
|
+
.map(([name, value]) => ` ${name}="${value}"`)
|
|
19
|
+
.join("");
|
|
20
|
+
return `<courier from="${from}"${extra}>\n${message}\n</courier>`;
|
|
21
|
+
}
|
|
22
|
+
function titleOf(task) {
|
|
23
|
+
const line = task.trim().split("\n")[0] ?? "";
|
|
24
|
+
return line.length > 60 ? `${line.slice(0, 57)}...` : line;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* The model a child runs on: its parent's, so it does not fall back to OpenCode's default, which
|
|
28
|
+
* the parent may have been moved off for a reason. An agent that names its own model keeps it.
|
|
29
|
+
*/
|
|
30
|
+
async function inheritedModel(ports, parentID, agent) {
|
|
31
|
+
const parent = await ports.session.get({ sessionID: parentID });
|
|
32
|
+
if (!parent.model || !agent)
|
|
33
|
+
return parent.model;
|
|
34
|
+
const named = await ports.agent.get({ agentID: agent, location: { directory: ports.directory } });
|
|
35
|
+
return named.data.model ? undefined : parent.model;
|
|
36
|
+
}
|
|
37
|
+
/** Creates a child session, hands it the task and returns at once; the child reports back with courier_send. */
|
|
38
|
+
export async function spawn(ports, parentID, input) {
|
|
39
|
+
// A failed lookup must not keep the child from starting; it then runs on OpenCode's default.
|
|
40
|
+
const model = await inheritedModel(ports, parentID, input.agent).catch(() => undefined);
|
|
41
|
+
const directory = input.isolate
|
|
42
|
+
? (await ports.worktree.create({ location: { directory: ports.directory } })).directory
|
|
43
|
+
: undefined;
|
|
44
|
+
const base = directory ? await ports.head(directory) : undefined;
|
|
45
|
+
const title = input.title ?? titleOf(input.task);
|
|
46
|
+
const child = await ports.session
|
|
47
|
+
.create({
|
|
48
|
+
title,
|
|
49
|
+
...(input.agent ? { agent: input.agent } : {}),
|
|
50
|
+
...(model ? { model } : {}),
|
|
51
|
+
...(directory ? { location: { directory } } : {}),
|
|
52
|
+
metadata: { courier: { parentID } },
|
|
53
|
+
})
|
|
54
|
+
.catch(async (error) => {
|
|
55
|
+
// No session will ever use the fresh worktree, and nothing records it, so it goes now.
|
|
56
|
+
if (directory)
|
|
57
|
+
await ports.worktree
|
|
58
|
+
.remove({ location: { directory: ports.directory }, directory, force: false })
|
|
59
|
+
.catch(() => undefined);
|
|
60
|
+
throw error;
|
|
61
|
+
});
|
|
62
|
+
// Recorded before the prompt, so a child that exists is on the roster even if prompting fails. A
|
|
63
|
+
// failed write must not keep the child from its task, so it is reported instead of thrown.
|
|
64
|
+
const rosterError = await record(ports.storage, {
|
|
65
|
+
sessionID: child.id,
|
|
66
|
+
parentID,
|
|
67
|
+
title,
|
|
68
|
+
directory: directory ?? child.location.directory,
|
|
69
|
+
isolated: directory !== undefined,
|
|
70
|
+
createdAt: ports.now(),
|
|
71
|
+
...(directory ? { source: ports.directory } : {}),
|
|
72
|
+
...(base ? { base } : {}),
|
|
73
|
+
}).then(() => undefined, (error) => describeFailure("roster", error).message);
|
|
74
|
+
await ports.session.prompt({ sessionID: child.id, text: childBrief(parentID, input.task) });
|
|
75
|
+
return {
|
|
76
|
+
sessionID: child.id,
|
|
77
|
+
directory: directory ?? child.location.directory,
|
|
78
|
+
...(rosterError ? { rosterError } : {}),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
/** Drops a message into another session's inbox; OpenCode wakes that session if it is idle. */
|
|
82
|
+
export async function send(ports, from, input) {
|
|
83
|
+
const delivered = await ports.session.synthetic({
|
|
84
|
+
sessionID: input.sessionID,
|
|
85
|
+
text: envelope(from, input.message),
|
|
86
|
+
description: `Message from ${from}`,
|
|
87
|
+
metadata: { source: "courier", from },
|
|
88
|
+
delivery: input.queue ? "queue" : "steer",
|
|
89
|
+
});
|
|
90
|
+
return { messageID: delivered.id };
|
|
91
|
+
}
|
|
92
|
+
/** A one-off look at a session, for check-ins; not meant to be called in a loop. */
|
|
93
|
+
export async function status(ports, input) {
|
|
94
|
+
const [info, messages, pending] = await Promise.all([
|
|
95
|
+
ports.session.get({ sessionID: input.sessionID }),
|
|
96
|
+
ports.session.context({ sessionID: input.sessionID }),
|
|
97
|
+
ports.pending(input.sessionID),
|
|
98
|
+
]);
|
|
99
|
+
const last = messages.findLast((message) => message.type === "assistant");
|
|
100
|
+
const lastText = last?.type === "assistant"
|
|
101
|
+
? last.content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("")
|
|
102
|
+
: undefined;
|
|
103
|
+
return withoutUndefined({
|
|
104
|
+
sessionID: info.id,
|
|
105
|
+
title: info.title,
|
|
106
|
+
parentID: info.parentID,
|
|
107
|
+
outcome: info.outcome,
|
|
108
|
+
updated: info.time.updated,
|
|
109
|
+
idle: info.time.idle,
|
|
110
|
+
lastText,
|
|
111
|
+
pending: pending.length ? pending : undefined,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
/** The sessions a parent started, each with what courier_status reports, or the error it gave. */
|
|
115
|
+
export async function listChildren(ports, parentID) {
|
|
116
|
+
const entries = await current(ports.storage, parentID, ports.now());
|
|
117
|
+
return Promise.all(entries.map(async (entry) => {
|
|
118
|
+
const roster = { directory: entry.directory, isolated: entry.isolated, created: entry.createdAt };
|
|
119
|
+
try {
|
|
120
|
+
return { ...(await status(ports, { sessionID: entry.sessionID })), ...roster };
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
return {
|
|
124
|
+
sessionID: entry.sessionID,
|
|
125
|
+
title: entry.title,
|
|
126
|
+
...roster,
|
|
127
|
+
error: describeFailure("courier_status", error).message,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
}));
|
|
131
|
+
}
|
|
132
|
+
/** OpenCode leaves a tool call hanging when its metadata holds `undefined`, so results drop those keys. */
|
|
133
|
+
function withoutUndefined(value) {
|
|
134
|
+
return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined));
|
|
135
|
+
}
|
|
136
|
+
/** A readable message for a failed courier call; OpenCode's own errors can carry an empty message. */
|
|
137
|
+
export function describeFailure(tool, error) {
|
|
138
|
+
const tagged = error;
|
|
139
|
+
const message = (typeof tagged?.message === "string" && tagged.message) ||
|
|
140
|
+
[tagged?._tag, tagged?.sessionID].filter((item) => typeof item === "string").join(" ") ||
|
|
141
|
+
String(error);
|
|
142
|
+
return new Error(`${tool} failed: ${message}`);
|
|
143
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { Plugin } from "@opencode-ai/plugin";
|
|
2
|
+
import { Plugin as EffectPlugin } from "@opencode-ai/plugin/effect";
|
|
3
|
+
import { type QuestionPorts } from "./question.js";
|
|
4
|
+
/** What a question relay needs from the plugin instance that wraps the question tool. */
|
|
5
|
+
export interface RelaySlot {
|
|
6
|
+
ports?: QuestionPorts;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* The courier tools, scheduler, webhook receiver and event watcher, as a promise plugin. `relay`
|
|
10
|
+
* receives this instance's ports for the question relay while it is loaded.
|
|
11
|
+
*/
|
|
12
|
+
export declare const courier: (relay?: RelaySlot) => Plugin.Plugin;
|
|
13
|
+
/**
|
|
14
|
+
* The plugin OpenCode loads. It is an Effect plugin, because the question relay wraps OpenCode's own
|
|
15
|
+
* question tool, whose execute the promise API hands out only as a promise that cannot be
|
|
16
|
+
* interrupted, so a child's question could not be withdrawn once its parent answered. Everything
|
|
17
|
+
* else is the promise plugin above, run through the plugin package's own adapter, as OpenCode runs
|
|
18
|
+
* a promise plugin.
|
|
19
|
+
*/
|
|
20
|
+
declare const _default: EffectPlugin.Plugin<import("effect/Scope").Scope>;
|
|
21
|
+
export default _default;
|