opencode-courier 0.4.0 → 0.5.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 +26 -15
- package/dist/index.js +1 -1
- package/dist/person/permission.d.ts +23 -2
- package/dist/person/permission.js +76 -17
- package/dist/person/text.d.ts +5 -0
- package/dist/person/text.js +21 -19
- package/dist/person/tools.d.ts +1 -1
- package/dist/person/tools.js +23 -12
- package/dist/tree/tui.d.ts +2 -2
- package/dist/tui.js +151 -12
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -22,7 +22,7 @@ prompt](#demos)).
|
|
|
22
22
|
## Quickstart
|
|
23
23
|
|
|
24
24
|
```bash
|
|
25
|
-
npm install -g @opencode/cli@2.0.
|
|
25
|
+
npm install -g @opencode/cli@2.0.29 # OpenCode V2, at the version courier is tested on
|
|
26
26
|
opencode plugin add opencode-courier # by name, so OpenCode offers new releases
|
|
27
27
|
opencode # in any folder
|
|
28
28
|
```
|
|
@@ -44,7 +44,7 @@ sometimes stall on a request; if nothing moves for a few minutes, send the promp
|
|
|
44
44
|
|
|
45
45
|
Courier is built on OpenCode V2's plugin API (`@opencode/plugin`), not the V1 one
|
|
46
46
|
(`@opencode-ai/plugin`), and needs OpenCode V2. Each release is pinned to one V2 version and tested
|
|
47
|
-
against it end to end; the current one is tested on **OpenCode 2.0.
|
|
47
|
+
against it end to end; the current one is tested on **OpenCode 2.0.29**. CI also runs the live
|
|
48
48
|
suite on the newest OpenCode release, so a host release that breaks the plugin shows up there
|
|
49
49
|
first. Older releases and their versions: [Supported OpenCode version](#supported-opencode-version).
|
|
50
50
|
|
|
@@ -64,8 +64,9 @@ a self-contained task that needs nothing from you on the way. Use courier when t
|
|
|
64
64
|
- **Worktree isolation:** `isolate: true` gives a child its own git worktree, so children can edit
|
|
65
65
|
files in parallel; each commits on `courier/<its session ID>` and reports that branch for its
|
|
66
66
|
parent to merge ([nested isolation](docs/reference.md#nested-isolation)).
|
|
67
|
-
- **Relaying to you:** a child's permission requests and questions reach you through the parent
|
|
68
|
-
|
|
67
|
+
- **Relaying to you:** a child's permission requests and questions reach you through the parent:
|
|
68
|
+
a request is approved in OpenCode's own permission prompt in the parent's session, never by its
|
|
69
|
+
model, and your answers go back; a form only you can answer is pointed out to you.
|
|
69
70
|
|
|
70
71
|
## How it works
|
|
71
72
|
|
|
@@ -116,7 +117,8 @@ line to the server log naming both ([more](docs/reference.md#the-opencode-versio
|
|
|
116
117
|
|
|
117
118
|
| opencode-courier | OpenCode V2 (`opencode` and `@opencode/plugin`) |
|
|
118
119
|
|---|---|
|
|
119
|
-
| 0.
|
|
120
|
+
| 0.5.0 | 2.0.29 |
|
|
121
|
+
| 0.4.0, 0.4.1 | 2.0.28 |
|
|
120
122
|
| 0.2.3, 0.3.0 | 2.0.26 |
|
|
121
123
|
| 0.2.2 | 2.0.24 |
|
|
122
124
|
| 0.2.1 | 2.0.23 |
|
|
@@ -124,12 +126,12 @@ line to the server log naming both ([more](docs/reference.md#the-opencode-versio
|
|
|
124
126
|
| 0.1.6 | 0.0.0-beta-19271 (the beta line: `opencode2` from `@opencode-ai/cli`, and `@opencode-ai/plugin`) |
|
|
125
127
|
|
|
126
128
|
The [terminal part](#in-the-terminal) relies on the host to provide its UI modules and is tested on
|
|
127
|
-
OpenCode 2.0.
|
|
129
|
+
OpenCode 2.0.29 only; the server part works as before on older V2 hosts from 2.0.4 on.
|
|
128
130
|
|
|
129
131
|
Check yours with `opencode --version`, and install the matching CLI with:
|
|
130
132
|
|
|
131
133
|
```bash
|
|
132
|
-
npm install -g @opencode/cli@2.0.
|
|
134
|
+
npm install -g @opencode/cli@2.0.29
|
|
133
135
|
```
|
|
134
136
|
|
|
135
137
|
### The plugin
|
|
@@ -161,7 +163,7 @@ From a local clone, `bun install && npm run build`, then list its `dist` **direc
|
|
|
161
163
|
as `opencode serve` next to it, cannot pass your answer on to a child the other runs
|
|
162
164
|
([why](docs/reference.md#two-servers-on-one-data-directory)).
|
|
163
165
|
2. Give the agents that run children the permissions their work needs: a child that hits an approval
|
|
164
|
-
prompt waits until you answer it
|
|
166
|
+
prompt waits until you answer OpenCode's prompt for it in the parent's session. Let them use the `question` tool if they
|
|
165
167
|
should be able to ask you. Choose a web search provider before children search the web (run one
|
|
166
168
|
web search in your own session, or use OpenCode's "Third-party search" setting), or the first
|
|
167
169
|
child to search shows that prompt in its own session, which only you can answer there.
|
|
@@ -188,7 +190,7 @@ From a local clone, `bun install && npm run build`, then list its `dist` **direc
|
|
|
188
190
|
| `courier_tree` | Lists the whole tree under a session, depth first, with each one's depth and report state. |
|
|
189
191
|
| `courier_stop` | Interrupts a session below you and everything under it, and cancels their scheduled messages; `cleanup: true` removes their worktrees too. |
|
|
190
192
|
| `courier_cleanup` | Removes an isolated child's worktree, unless it holds unsaved work, and forgets the child. |
|
|
191
|
-
| `courier_answer` |
|
|
193
|
+
| `courier_answer` | Answers a permission request that a session under this one waits on, after OpenCode has asked you in this session, or passes your answer to its question. |
|
|
192
194
|
| `courier_later` | Schedules a message for a session (this one by default) in `delayMinutes` or `at` a time. |
|
|
193
195
|
| `courier_cancel` | Drops a message scheduled with `courier_later`. |
|
|
194
196
|
| `courier_subscribe` | Subscribes a session to webhook deliveries for a topic (`owner/repo`, `owner/repo#12` or a name). |
|
|
@@ -212,9 +214,11 @@ Each tool's parameters: [the reference](docs/reference.md#the-tools).
|
|
|
212
214
|
children, a scheduled message or a webhook is not, nor is one that answers what you typed in its
|
|
213
215
|
session. [More](docs/reference.md#a-child-that-fails).
|
|
214
216
|
- **Permission requests and questions reach you.** A child's request or question is passed to the
|
|
215
|
-
session at the top of its tree, any number of levels down
|
|
216
|
-
answer
|
|
217
|
-
|
|
217
|
+
session at the top of its tree, any number of levels down. For a request, that session proposes an
|
|
218
|
+
answer with `courier_answer`, and OpenCode asks you in its own permission prompt, in that session
|
|
219
|
+
and under its rules, before anything is passed on: its model cannot approve, only propose. For a
|
|
220
|
+
question, it asks you and passes your answer back, or links its own question to the child's so your
|
|
221
|
+
pick goes straight to it. Whichever answer comes first counts, and the other is withdrawn.
|
|
218
222
|
[More](docs/reference.md#a-child-that-asks-for-permission).
|
|
219
223
|
- **Some forms only you can answer**, such as OpenCode's web search asking for a provider: the top
|
|
220
224
|
session is told, and you answer in the child's session. [More](docs/reference.md#a-child-that-shows-a-form).
|
|
@@ -264,14 +268,20 @@ courier 2 running · 1 waiting on you · group pair 1/2
|
|
|
264
268
|
by depth, with what it does, its report and its group's standing; a session that started others gets
|
|
265
269
|
a bar of the parts reported, a leaf how long it ran, its last tool call and its cost. `↑`/`↓` select,
|
|
266
270
|
`enter` opens the session, `s` stops it and everything under it after you confirm, as `courier_stop`
|
|
267
|
-
would, and `q` closes the panel; `/courier stop <session>` stops from the prompt.
|
|
271
|
+
would, and `q` closes the panel; `/courier stop <session>` stops from the prompt. While open, the panel
|
|
272
|
+
follows you between sessions: into one of its tree it keeps that tree, the session on screen marked
|
|
273
|
+
`(on screen)`; elsewhere it shows that session's tree, or hides while you are in a session without one,
|
|
274
|
+
or on the home screen, and shows again once you are back in one; only `q` closes it for good.
|
|
268
275
|
|
|
269
|
-
`ctrl+x k` (the leader key, then `k`), `/asks
|
|
276
|
+
`ctrl+x k` (the leader key, then `k`), `/asks` (or `/ask`), the palette's "Courier: what waits on you" or a click
|
|
270
277
|
on "waiting on you" opens the asks inbox: every permission request, question and form waiting
|
|
271
278
|
anywhere in the tree, oldest first, with where it comes from, answered in a dialog with no model in
|
|
272
279
|
between; the session that started the tree hears once that you settled it. The TUI plays OpenCode's
|
|
273
280
|
sounds for a child's report, a group's release and a new ask ([more](docs/reference.md#in-the-terminal)).
|
|
274
281
|
|
|
282
|
+
While a permission prompt or form is shown in the session, OpenCode hides the footer, so open the
|
|
283
|
+
inbox with `ctrl+x k` (or `/asks` once the prompt is back).
|
|
284
|
+
|
|
275
285
|
## Native child sessions
|
|
276
286
|
|
|
277
287
|
Off by default. With `"nativeChildren": true` in the plugin's `options`, a child started without
|
|
@@ -293,7 +303,8 @@ the courier's own tree, limits and relays work as without the option. What chang
|
|
|
293
303
|
top session's model is only told. `opencode run` and the TUI's auto-accept mode answer them as their
|
|
294
304
|
own subagents'.
|
|
295
305
|
- **Turn it on only with OpenCode's TUI or the paired app**, which show those prompts; with a bare
|
|
296
|
-
API client nobody prompts you, and only `courier_answer` is left
|
|
306
|
+
API client nobody prompts you, and only `courier_answer` is left, which prompts you in the top
|
|
307
|
+
session before it passes anything on.
|
|
297
308
|
|
|
298
309
|
## Webhooks
|
|
299
310
|
|
package/dist/index.js
CHANGED
|
@@ -95,7 +95,7 @@ export const courier = () => Plugin.define({
|
|
|
95
95
|
},
|
|
96
96
|
};
|
|
97
97
|
const cleanupPorts = { ...ports, inspect: inspectWorktree };
|
|
98
|
-
const answerPorts = { storage: ctx.storage, permissions };
|
|
98
|
+
const answerPorts = { storage: ctx.storage, permissions, assert: (input) => ctx.permission.assert(input) };
|
|
99
99
|
const later = {
|
|
100
100
|
storage: ctx.storage,
|
|
101
101
|
session: ctx.session,
|
|
@@ -3,6 +3,12 @@ import { type Pending, type RosterStorage } from "../tree/index.js";
|
|
|
3
3
|
/** The answers to a permission request, as OpenCode's own prompt offers them. */
|
|
4
4
|
export declare const REPLIES: readonly ["once", "always", "reject"];
|
|
5
5
|
export type Reply = (typeof REPLIES)[number];
|
|
6
|
+
/**
|
|
7
|
+
* What courier_answer passes on: `once`, which OpenCode first asks the person about in the caller's
|
|
8
|
+
* session, or `reject`. Never `always`: the plugin grants no standing rule to the caller or the child.
|
|
9
|
+
*/
|
|
10
|
+
export declare const PROPOSALS: readonly ["once", "reject"];
|
|
11
|
+
export type Proposal = (typeof PROPOSALS)[number];
|
|
6
12
|
/** A permission request, as OpenCode's `permission.asked` event carries it. */
|
|
7
13
|
export interface PermissionRequest {
|
|
8
14
|
readonly id: string;
|
|
@@ -25,6 +31,8 @@ export interface PermissionAnswered {
|
|
|
25
31
|
type Context = Plugin.Context;
|
|
26
32
|
/** One location's pending permission requests. OpenCode keeps them per location, so an isolated child's are in its worktree's. */
|
|
27
33
|
export type Permissions = Pick<Context["permission"], "list" | "reply">;
|
|
34
|
+
/** OpenCode's check of a session's permission rules, which asks the person in that session when they say so. */
|
|
35
|
+
export type Assert = Context["permission"]["assert"];
|
|
28
36
|
/** The part of OpenCode's `permission.asked` event the notice is made from. */
|
|
29
37
|
export interface PermissionAsked {
|
|
30
38
|
readonly id: string;
|
|
@@ -48,12 +56,20 @@ export interface AnswerPorts {
|
|
|
48
56
|
readonly storage: RosterStorage;
|
|
49
57
|
/** The permission domains of every loaded location; the request is answered where it is pending. */
|
|
50
58
|
readonly permissions: () => Iterable<Permissions>;
|
|
59
|
+
/** The caller's location's check, which prompts the person in the caller's session. */
|
|
60
|
+
readonly assert: Assert;
|
|
61
|
+
}
|
|
62
|
+
/** The courier_answer call a proposal comes from, which OpenCode's prompt in the caller's session is bound to. */
|
|
63
|
+
export interface AnswerCall {
|
|
64
|
+
readonly messageID: string;
|
|
65
|
+
readonly callID: string;
|
|
51
66
|
}
|
|
52
67
|
export interface AnswerInput {
|
|
53
68
|
readonly sessionID: string;
|
|
54
69
|
readonly requestID: string;
|
|
55
70
|
readonly reply: string;
|
|
56
71
|
readonly message?: string;
|
|
72
|
+
readonly call?: AnswerCall;
|
|
57
73
|
}
|
|
58
74
|
/** A session's pending requests in every location, with the domain holding each; a location that cannot be read gives its error. */
|
|
59
75
|
export declare function listEverywhere<D extends Pick<Permissions, "list">>(permissions: Iterable<D>, sessionID: string): Promise<({
|
|
@@ -69,8 +85,13 @@ export declare function asksPermission(ports: {
|
|
|
69
85
|
readonly permissions: () => Iterable<Pick<Permissions, "list">>;
|
|
70
86
|
}, sessionID: string): Promise<boolean>;
|
|
71
87
|
/**
|
|
72
|
-
*
|
|
73
|
-
* which
|
|
88
|
+
* Whether `permission.assert` failed because the person rejected the caller's prompt without a reason,
|
|
89
|
+
* which OpenCode ends the caller's step on; the tool rethrows it as it is, and the child's request waits on.
|
|
90
|
+
*/
|
|
91
|
+
export declare const isDeclined: (error: unknown) => boolean;
|
|
92
|
+
/**
|
|
93
|
+
* Answers a permission request of a session started, directly or through others, from the caller, which must be the top
|
|
94
|
+
* session, a proposed `once` only once OpenCode allowed it there; `answered` is false when no location loaded here holds it.
|
|
74
95
|
*/
|
|
75
96
|
export declare function answer(ports: AnswerPorts, waiting: Waiting, callerID: string, input: AnswerInput): Promise<PermissionAnswered>;
|
|
76
97
|
/** The requests a session waits on, in whichever location holds them, for courier_status. */
|
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
import { describeFailure } from "../kernel/protocol.js";
|
|
2
|
-
import { REJECTED } from "./text.js";
|
|
2
|
+
import { REJECTED, refusedText } from "./text.js";
|
|
3
3
|
import { answeringTop } from "../tree/index.js";
|
|
4
4
|
/** The answers to a permission request, as OpenCode's own prompt offers them. */
|
|
5
5
|
export const REPLIES = ["once", "always", "reject"];
|
|
6
|
+
/**
|
|
7
|
+
* What courier_answer passes on: `once`, which OpenCode first asks the person about in the caller's
|
|
8
|
+
* session, or `reject`. Never `always`: the plugin grants no standing rule to the caller or the child.
|
|
9
|
+
*/
|
|
10
|
+
export const PROPOSALS = ["once", "reject"];
|
|
6
11
|
/** A session's pending requests in every location, with the domain holding each; a location that cannot be read gives its error. */
|
|
7
12
|
export function listEverywhere(permissions, sessionID) {
|
|
8
13
|
return Promise.all([...permissions].map((domain) => domain.list({ sessionID }).then((requests) => ({ domain, requests: requests.filter((request) => request.sessionID === sessionID) }), (error) => ({ domain, requests: [], error }))));
|
|
@@ -24,28 +29,58 @@ async function locate(ports, sessionID, requestID) {
|
|
|
24
29
|
throw failed.error;
|
|
25
30
|
return undefined;
|
|
26
31
|
}
|
|
32
|
+
const tagOf = (value) => value?._tag;
|
|
27
33
|
/**
|
|
28
|
-
*
|
|
29
|
-
* which
|
|
34
|
+
* Whether `permission.assert` failed because the person rejected the caller's prompt without a reason,
|
|
35
|
+
* which OpenCode ends the caller's step on; the tool rethrows it as it is, and the child's request waits on.
|
|
30
36
|
*/
|
|
31
|
-
export
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
const
|
|
38
|
-
if (
|
|
39
|
-
|
|
40
|
-
|
|
37
|
+
export const isDeclined = (error) => tagOf(error) === "Permission.DeclinedError";
|
|
38
|
+
/** Why `permission.assert` refused: the caller's rules deny the action, or the person rejected the prompt with a reason. */
|
|
39
|
+
function refusalOf(error) {
|
|
40
|
+
if (tagOf(error) !== "Tool.Error")
|
|
41
|
+
return undefined;
|
|
42
|
+
const { message, error: cause } = error;
|
|
43
|
+
const tag = tagOf(cause);
|
|
44
|
+
if (tag !== "Permission.BlockedError" && tag !== "Permission.CorrectedError")
|
|
45
|
+
return undefined;
|
|
46
|
+
return typeof message === "string" && message ? message : "not allowed";
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* OpenCode's check of the child's request under the caller's rules, which asks the person in the caller's session, with the
|
|
50
|
+
* request's own action and resources and nothing to save: the reason when refused with one, nothing when allowed, else thrown.
|
|
51
|
+
*/
|
|
52
|
+
async function askCaller(ports, callerID, request, call) {
|
|
53
|
+
try {
|
|
54
|
+
await ports.assert({
|
|
55
|
+
sessionID: callerID,
|
|
56
|
+
action: request.action,
|
|
57
|
+
resources: [...request.resources],
|
|
58
|
+
metadata: { source: "courier", from: request.sessionID, asks: "permission", requestID: request.id },
|
|
59
|
+
...(call ? { source: { type: "tool", messageID: call.messageID, id: call.callID } } : {}),
|
|
60
|
+
});
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
const reason = refusalOf(error);
|
|
65
|
+
if (reason === undefined)
|
|
66
|
+
throw error;
|
|
67
|
+
return reason;
|
|
41
68
|
}
|
|
42
|
-
|
|
43
|
-
|
|
69
|
+
}
|
|
70
|
+
/** The reply as a proposal courier_answer passes on; anything else, `always` in particular, is refused. */
|
|
71
|
+
function proposalOf(reply) {
|
|
72
|
+
if (PROPOSALS.includes(reply))
|
|
73
|
+
return reply;
|
|
74
|
+
const always = reply === "always" ? ": courier_answer grants no standing rule; the person grants one in OpenCode's own prompt, in the asking session or the asks inbox" : "";
|
|
75
|
+
throw new Error(`reply must be once or reject, not "${reply}"${always}.`);
|
|
76
|
+
}
|
|
77
|
+
/** Replies where the request is pending; a reply that fails leaves the request waiting, unless it no longer does. */
|
|
78
|
+
async function passOn(ports, waiting, found, reply) {
|
|
79
|
+
const { sessionID, requestID, decision, message } = reply;
|
|
44
80
|
// Off the waiting list first, so the reply event this answer causes does not come back as a notice.
|
|
45
81
|
waiting.delete(requestID);
|
|
46
|
-
const message = reply === "reject" ? input.message || REJECTED : undefined;
|
|
47
82
|
try {
|
|
48
|
-
await found.domain.reply({ sessionID, requestID, decision
|
|
83
|
+
await found.domain.reply({ sessionID, requestID, decision, ...(message ? { message } : {}) });
|
|
49
84
|
}
|
|
50
85
|
catch (error) {
|
|
51
86
|
if (await locate(ports, sessionID, requestID).catch(() => found)) {
|
|
@@ -54,6 +89,30 @@ export async function answer(ports, waiting, callerID, input) {
|
|
|
54
89
|
}
|
|
55
90
|
throw new Error(`${describeFailure("permission.reply", error).message}, but ${requestID} no longer waits, so it was answered, possibly by this call.`);
|
|
56
91
|
}
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Answers a permission request of a session started, directly or through others, from the caller, which must be the top
|
|
95
|
+
* session, a proposed `once` only once OpenCode allowed it there; `answered` is false when no location loaded here holds it.
|
|
96
|
+
*/
|
|
97
|
+
export async function answer(ports, waiting, callerID, input) {
|
|
98
|
+
const reply = proposalOf(input.reply);
|
|
99
|
+
const { sessionID, requestID } = input;
|
|
100
|
+
await answeringTop(ports.storage, sessionID, callerID, "permission requests");
|
|
101
|
+
let found = await locate(ports, sessionID, requestID);
|
|
102
|
+
let refused;
|
|
103
|
+
if (found && reply === "once") {
|
|
104
|
+
refused = await askCaller(ports, callerID, found.request, input.call);
|
|
105
|
+
// Answered meanwhile, in the child's session or the asks inbox, while the person was asked.
|
|
106
|
+
found = await locate(ports, sessionID, requestID);
|
|
107
|
+
}
|
|
108
|
+
if (!found) {
|
|
109
|
+
waiting.delete(requestID);
|
|
110
|
+
return { sessionID, requestID, reply, answered: false };
|
|
111
|
+
}
|
|
112
|
+
const message = refused ?? (reply === "reject" ? input.message || REJECTED : undefined);
|
|
113
|
+
await passOn(ports, waiting, found, { sessionID, requestID, decision: refused === undefined ? reply : "reject", ...(message ? { message } : {}) });
|
|
114
|
+
if (refused !== undefined)
|
|
115
|
+
throw new Error(refusedText(sessionID, requestID, refused));
|
|
57
116
|
return { sessionID, requestID, reply, answered: true };
|
|
58
117
|
}
|
|
59
118
|
/** The requests a session waits on, in whichever location holds them, for courier_status. */
|
package/dist/person/text.d.ts
CHANGED
|
@@ -10,6 +10,11 @@ export declare function answerText(result: AnswerResult): string;
|
|
|
10
10
|
* rejection, and the child would never report back; with a message, the call fails and it carries on.
|
|
11
11
|
*/
|
|
12
12
|
export declare const REJECTED = "Refused by the session that started you. Do without it, or report back why you cannot.";
|
|
13
|
+
/**
|
|
14
|
+
* The failure of a courier_answer whose proposed once OpenCode refused in the caller's session: a rule
|
|
15
|
+
* denies the action there, or the person rejected the prompt with a reason, which the child was given.
|
|
16
|
+
*/
|
|
17
|
+
export declare function refusedText(sessionID: string, requestID: string, reason: string): string;
|
|
13
18
|
/** How a notice names where the asking session came from, to the session at the top of its lineage. */
|
|
14
19
|
export declare const origin: (startedBy?: string) => string;
|
|
15
20
|
/** For a notice that a session's turn ended without it finishing. */
|
package/dist/person/text.js
CHANGED
|
@@ -12,8 +12,10 @@ export function answerText(result) {
|
|
|
12
12
|
if (!result.answered)
|
|
13
13
|
return (`${result.sessionID} no longer waits on question ${result.requestID}: it was answered some other way, or the ` +
|
|
14
14
|
"session stopped waiting. Nothing was passed on; tell the person their answer is not needed.");
|
|
15
|
+
if ("reply" in result && result.reply === "reject")
|
|
16
|
+
return `Passed on reject for request ${result.requestID} of ${result.sessionID}; its call fails and it carries on, and reports back with courier_send. ${END_TURN}`;
|
|
15
17
|
if ("reply" in result)
|
|
16
|
-
return `
|
|
18
|
+
return `Allowed in your session, by the person in OpenCode's prompt or by your rules, and passed on ${result.reply} for request ${result.requestID} of ${result.sessionID}, which carries on and reports back with courier_send. ${END_TURN}`;
|
|
17
19
|
const how = result.by === "message" ? " as a message, since its question had been cut off" : "";
|
|
18
20
|
return `Passed the answers to question ${result.requestID} on to ${result.sessionID}${how}; it carries on and reports back with courier_send. ${END_TURN}`;
|
|
19
21
|
}
|
|
@@ -23,16 +25,16 @@ export function answerText(result) {
|
|
|
23
25
|
* rejection, and the child would never report back; with a message, the call fails and it carries on.
|
|
24
26
|
*/
|
|
25
27
|
export const REJECTED = "Refused by the session that started you. Do without it, or report back why you cannot.";
|
|
28
|
+
/**
|
|
29
|
+
* The failure of a courier_answer whose proposed once OpenCode refused in the caller's session: a rule
|
|
30
|
+
* denies the action there, or the person rejected the prompt with a reason, which the child was given.
|
|
31
|
+
*/
|
|
32
|
+
export function refusedText(sessionID, requestID, reason) {
|
|
33
|
+
return `not allowed in your session (${reason}). The refusal was passed on with that reason to request ${requestID} of ${sessionID}, so its call fails and it carries on; it reports back with courier_send. Tell the person, and do not propose it again unless they ask.`;
|
|
34
|
+
}
|
|
26
35
|
const MAX_RESOURCES = 20;
|
|
27
36
|
const MAX_RESOURCE_LENGTH = 300;
|
|
28
37
|
const clip = (text) => (text.length > MAX_RESOURCE_LENGTH ? `${text.slice(0, MAX_RESOURCE_LENGTH - 3)}...` : text);
|
|
29
|
-
function alwaysChoice(request) {
|
|
30
|
-
const save = request.save ?? [];
|
|
31
|
-
if (!save.length)
|
|
32
|
-
return [];
|
|
33
|
-
const scope = save.length === 1 && save[0] === "*" ? `every ${request.action} request` : `requests matching ${save.join(", ")}`;
|
|
34
|
-
return [`- always: allow it, and from now on ${scope} in this project`];
|
|
35
|
-
}
|
|
36
38
|
/** How a notice names where the asking session came from, to the session at the top of its lineage. */
|
|
37
39
|
export const origin = (startedBy) => startedBy ? `which ${startedBy} started with courier_spawn, a session started from yours,` : "which you started with courier_spawn,";
|
|
38
40
|
/** For a notice that a session's turn ended without it finishing. */
|
|
@@ -51,21 +53,21 @@ export function permissionNotice(title, request, startedBy, native = false) {
|
|
|
51
53
|
...(resources.length ? ["On:", ...resources] : []),
|
|
52
54
|
...(request.message ? [`Note: ${request.message}`] : []),
|
|
53
55
|
"",
|
|
54
|
-
...(native ?
|
|
55
|
-
"- once: allow this request only",
|
|
56
|
-
...alwaysChoice(request),
|
|
57
|
-
"- reject: refuse it, with a reason if they give one; the session's call fails and it carries on",
|
|
58
|
-
`When they have chosen, call courier_answer with sessionID "${request.sessionID}", requestID "${request.id}", reply set to their choice and, with reject, message set to their reason.`,
|
|
56
|
+
...(native ? nativePermission(request) : permissionAsk(request)),
|
|
59
57
|
].join("\n");
|
|
60
58
|
}
|
|
61
|
-
const
|
|
62
|
-
"
|
|
59
|
+
const permissionAsk = (request) => [
|
|
60
|
+
"You cannot decide this: the person you are working with does, in OpenCode's own permission prompt, which opens in your session when you propose once. Do not ask them in your reply or with your question tool first; propose one of these:",
|
|
61
|
+
"- once: allow this request only; OpenCode first asks the person in your session, under your permission rules, and it is passed on only once they have allowed it",
|
|
62
|
+
"- reject: refuse it, with a reason if the person gave one; the session's call fails and it carries on",
|
|
63
|
+
`Call courier_answer with sessionID "${request.sessionID}", requestID "${request.id}", reply set to the choice and, with reject, message set to the reason.`,
|
|
64
|
+
"Propose once unless the person has told you to refuse it, or the request plainly goes against what they asked of you; with once, the call returns when they have answered the prompt (or your rules allow the action without one). A refusal there with a reason fails the call with it, which the session is given; one without a reason ends your turn, and the session waits on.",
|
|
63
65
|
];
|
|
64
|
-
/** For a native child's request, which OpenCode shows the person itself, in the top session's view. */
|
|
65
|
-
const
|
|
66
|
+
/** For a native child's request, which OpenCode shows the person itself, in the top session's view: nothing to call. */
|
|
67
|
+
const nativePermission = (request) => [
|
|
66
68
|
"OpenCode already shows this request to the person you are working with, as its own permission prompt in your session (and in the paired app's request dock), where they answer it; you will be told once it is answered.",
|
|
67
|
-
"Do not ask them yourself, with your question tool or in your reply, and do not
|
|
68
|
-
|
|
69
|
+
"Do not ask them yourself, with your question tool or in your reply, and do not call courier_answer for it, which would only ask them a second time: at most tell them in one line that this session waits on their permission, then end your turn.",
|
|
70
|
+
`Only if they tell you to answer it for them, call courier_answer with sessionID "${request.sessionID}", requestID "${request.id}" and reply set to once or reject (with reject, message set to the reason); with once, OpenCode asks them again in your session before it is passed on.`,
|
|
69
71
|
];
|
|
70
72
|
/** What the session told about a permission request is told once it is answered without courier_answer. */
|
|
71
73
|
export function permissionSettledNotice(title, requestID, reply) {
|
package/dist/person/tools.d.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { type QuestionPorts } from "./question/index.js";
|
|
|
5
5
|
export declare const AnswerInput: Schema.Struct<{
|
|
6
6
|
readonly sessionID: Schema.String;
|
|
7
7
|
readonly requestID: Schema.String;
|
|
8
|
-
readonly reply: Schema.optional<Schema.Literals<readonly ["once", "
|
|
8
|
+
readonly reply: Schema.optional<Schema.Literals<readonly ["once", "reject"]>>;
|
|
9
9
|
readonly message: Schema.optional<Schema.String>;
|
|
10
10
|
readonly answers: Schema.optional<Schema.$Array<Schema.Union<readonly [Schema.String, Schema.$Array<Schema.String>]>>>;
|
|
11
11
|
}>;
|
package/dist/person/tools.js
CHANGED
|
@@ -1,22 +1,23 @@
|
|
|
1
1
|
import { Schema } from "effect";
|
|
2
2
|
import { rethrow } from "../kernel/tool.js";
|
|
3
|
-
import { answer,
|
|
3
|
+
import { answer, isDeclined, PROPOSALS } from "./permission.js";
|
|
4
4
|
import { answerQuestion, isQuestion } from "./question/index.js";
|
|
5
5
|
import { answerText } from "./text.js";
|
|
6
6
|
export const AnswerInput = Schema.Struct({
|
|
7
7
|
sessionID: Schema.String.annotate({ description: "The session you started that is waiting, as its notice names it." }),
|
|
8
8
|
requestID: Schema.String.annotate({ description: "The request id from the notice." }),
|
|
9
|
-
reply: Schema.optional(Schema.Literals(
|
|
10
|
-
description: "For a permission request: the
|
|
9
|
+
reply: Schema.optional(Schema.Literals(PROPOSALS).annotate({
|
|
10
|
+
description: "For a permission request: the answer you propose, once or reject. With once, OpenCode first asks the person in " +
|
|
11
|
+
"your session, and it is passed on only once they have allowed it; always is not granted through this tool.",
|
|
11
12
|
})),
|
|
12
|
-
message: Schema.optional(Schema.String.annotate({ description: "For a permission request, with reject: the
|
|
13
|
+
message: Schema.optional(Schema.String.annotate({ description: "For a permission request, with reject: the reason, passed on with the refusal." })),
|
|
13
14
|
answers: Schema.optional(Schema.Array(Schema.Union([Schema.String, Schema.Array(Schema.String)])).annotate({
|
|
14
15
|
description: "For a question: one entry per question, in order, each the label the person chose or the text they gave, " +
|
|
15
16
|
"or a list of labels where the question allows several.",
|
|
16
17
|
})),
|
|
17
18
|
});
|
|
18
19
|
// One tool answers both: a question's id is the plugin's own, a permission request's OpenCode's.
|
|
19
|
-
async function answerRequest(ports, callerID, input) {
|
|
20
|
+
async function answerRequest(ports, callerID, input, call) {
|
|
20
21
|
const { sessionID, requestID } = input;
|
|
21
22
|
if (isQuestion(requestID)) {
|
|
22
23
|
if (input.reply !== undefined || input.message !== undefined)
|
|
@@ -26,14 +27,15 @@ async function answerRequest(ports, callerID, input) {
|
|
|
26
27
|
return answerQuestion(ports.questions, callerID, { sessionID, requestID, answers: input.answers });
|
|
27
28
|
}
|
|
28
29
|
if (input.answers !== undefined)
|
|
29
|
-
throw new Error(`${requestID} is a permission request: pass the
|
|
30
|
+
throw new Error(`${requestID} is a permission request: pass the answer you propose in reply (once or reject), not answers.`);
|
|
30
31
|
if (input.reply === undefined)
|
|
31
|
-
throw new Error(`${requestID} is a permission request: reply is required, once
|
|
32
|
+
throw new Error(`${requestID} is a permission request: reply is required, once or reject.`);
|
|
32
33
|
return answer(ports.answer, ports.waiting, callerID, {
|
|
33
34
|
sessionID,
|
|
34
35
|
requestID,
|
|
35
36
|
reply: input.reply,
|
|
36
37
|
...(input.message !== undefined ? { message: input.message } : {}),
|
|
38
|
+
...(call ? { call } : {}),
|
|
37
39
|
});
|
|
38
40
|
}
|
|
39
41
|
/** Adds `courier_answer`. */
|
|
@@ -42,13 +44,22 @@ export function addPersonTools(tools, ports) {
|
|
|
42
44
|
name: "courier_answer",
|
|
43
45
|
options: { codemode: false },
|
|
44
46
|
description: "Pass on the answer to a permission request or a question that a session you started with courier_spawn (or " +
|
|
45
|
-
"one started from it) waits on, after a notice from it named the request.
|
|
46
|
-
"
|
|
47
|
-
"
|
|
48
|
-
"
|
|
47
|
+
"one started from it) waits on, after a notice from it named the request. For a permission request, reply is " +
|
|
48
|
+
"the answer you propose, once or reject: proposing is not deciding. With once, OpenCode asks the person you are " +
|
|
49
|
+
"working with in your session, as its own permission prompt for what that session asked, under your permission " +
|
|
50
|
+
"rules, and the call returns once they have answered; what they allow is passed on, and a refusal with a reason " +
|
|
51
|
+
"fails this call and is passed on with the reason. Do not ask them in your reply or with your question tool first. " +
|
|
52
|
+
"For a question, answers is what the person chose: ask them first, with your question tool if you have one, and " +
|
|
53
|
+
"never answer for them. The session carries on once it has the answer.",
|
|
49
54
|
input: AnswerInput,
|
|
50
55
|
execute: async (input, context) => {
|
|
51
|
-
const
|
|
56
|
+
const call = { messageID: context.messageID, callID: context.id };
|
|
57
|
+
const result = await answerRequest(ports, context.sessionID, input, call).catch((error) => {
|
|
58
|
+
// A plain rejection of the caller's prompt ends its step, as OpenCode's own tools do: it goes back as it is.
|
|
59
|
+
if (isDeclined(error))
|
|
60
|
+
throw error;
|
|
61
|
+
return rethrow("courier_answer")(error);
|
|
62
|
+
});
|
|
52
63
|
return { content: answerText(result), metadata: result };
|
|
53
64
|
},
|
|
54
65
|
});
|
package/dist/tree/tui.d.ts
CHANGED
|
@@ -41,8 +41,8 @@ export declare function stoppedLine({ stopped, cancelled }: Stopped): string;
|
|
|
41
41
|
/** The usage the `/courier` command shows for an input it does not know. */
|
|
42
42
|
export declare const USAGE = "/courier tree opens the session tree; /courier stop [session] stops a session and those under it";
|
|
43
43
|
/**
|
|
44
|
-
* Adds the tree panel to the `session.panel` slot, opened by `/courier tree` or the
|
|
45
|
-
* stop <session>`, which stops a session under the open one
|
|
44
|
+
* Adds the tree panel to the `session.panel` slot, opened by `/courier tree` or the palette, which follows the person
|
|
45
|
+
* between sessions until closed, and `/courier stop <session>`, which stops a session under the open one once confirmed.
|
|
46
46
|
*/
|
|
47
47
|
export declare function addTreePanel(ctx: Context): () => void;
|
|
48
48
|
/**
|
package/dist/tui.js
CHANGED
|
@@ -518,6 +518,7 @@ function addAsksInbox(ctx) {
|
|
|
518
518
|
}, SETTLE_MS);
|
|
519
519
|
});
|
|
520
520
|
ctx.keymap.layer(() => ({
|
|
521
|
+
mode: "global",
|
|
521
522
|
commands: [{
|
|
522
523
|
id: INBOX_COMMAND,
|
|
523
524
|
title: "Courier: what waits on you",
|
|
@@ -526,7 +527,8 @@ function addAsksInbox(ctx) {
|
|
|
526
527
|
bind: "<leader>k",
|
|
527
528
|
palette: true,
|
|
528
529
|
slash: {
|
|
529
|
-
name: "asks"
|
|
530
|
+
name: "asks",
|
|
531
|
+
aliases: ["ask"]
|
|
530
532
|
},
|
|
531
533
|
run: () => void open()
|
|
532
534
|
}]
|
|
@@ -550,7 +552,7 @@ import { createTextNode as _$createTextNode } from "@opentui/solid";
|
|
|
550
552
|
import { insertNode as _$insertNode } from "@opentui/solid";
|
|
551
553
|
import { setProp as _$setProp } from "@opentui/solid";
|
|
552
554
|
import { createElement as _$createElement } from "@opentui/solid";
|
|
553
|
-
import { createEffect, createResource, createSignal, For, onCleanup, Show } from "solid-js";
|
|
555
|
+
import { createEffect, createResource, createRoot, createSignal, For, on, onCleanup, onMount, Show, untrack } from "solid-js";
|
|
554
556
|
|
|
555
557
|
// src/tree/rpc.ts
|
|
556
558
|
import { Rpc as Rpc2 } from "@opencode/plugin/rpc";
|
|
@@ -813,10 +815,12 @@ async function confirmStop(ctx, courier, location, by, target) {
|
|
|
813
815
|
}
|
|
814
816
|
return true;
|
|
815
817
|
}
|
|
818
|
+
var FOCUS_PROMPT = "pane.focus.left";
|
|
816
819
|
function TreePanel(props) {
|
|
817
820
|
const {
|
|
818
821
|
ctx,
|
|
819
822
|
input,
|
|
823
|
+
view,
|
|
820
824
|
courier,
|
|
821
825
|
location
|
|
822
826
|
} = props;
|
|
@@ -824,7 +828,7 @@ function TreePanel(props) {
|
|
|
824
828
|
const [read, {
|
|
825
829
|
refetch
|
|
826
830
|
}] = createResource(() => ({
|
|
827
|
-
sessionID:
|
|
831
|
+
sessionID: view.root,
|
|
828
832
|
changed: props.changed()
|
|
829
833
|
}), ({
|
|
830
834
|
sessionID
|
|
@@ -839,12 +843,40 @@ function TreePanel(props) {
|
|
|
839
843
|
sessionID,
|
|
840
844
|
error: reason(error)
|
|
841
845
|
})));
|
|
842
|
-
const latest = () => read.state === "ready" || read.state === "refreshing" ? read.latest :
|
|
846
|
+
const latest = () => read.state === "ready" || read.state === "refreshing" ? read.latest : view.tree && {
|
|
847
|
+
sessionID: view.root,
|
|
848
|
+
tree: view.tree
|
|
849
|
+
};
|
|
843
850
|
const rows = () => latest()?.tree?.rows ?? [];
|
|
851
|
+
createEffect(() => {
|
|
852
|
+
const tree = latest()?.tree;
|
|
853
|
+
if (tree)
|
|
854
|
+
view.note({
|
|
855
|
+
tree
|
|
856
|
+
});
|
|
857
|
+
});
|
|
858
|
+
createEffect(() => view.note({
|
|
859
|
+
presentation: input.presentation,
|
|
860
|
+
focused: input.focused
|
|
861
|
+
}));
|
|
862
|
+
onMount(() => {
|
|
863
|
+
if (!view.refocus)
|
|
864
|
+
return;
|
|
865
|
+
const giveBack = setTimeout(() => ctx.keymap.dispatch(FOCUS_PROMPT));
|
|
866
|
+
onCleanup(() => clearTimeout(giveBack));
|
|
867
|
+
});
|
|
844
868
|
const [now, setNow] = createSignal(Date.now());
|
|
845
869
|
const tick = setInterval(() => setNow(Date.now()), TICK_MS);
|
|
846
870
|
onCleanup(() => clearInterval(tick));
|
|
847
871
|
const [selected, setSelected] = createSignal(0);
|
|
872
|
+
let placed = false;
|
|
873
|
+
createEffect(() => {
|
|
874
|
+
const index = placed ? -1 : rows().findIndex((row) => row.sessionID === input.sessionID);
|
|
875
|
+
if (index < 0)
|
|
876
|
+
return;
|
|
877
|
+
placed = true;
|
|
878
|
+
setSelected(index);
|
|
879
|
+
});
|
|
848
880
|
const current = () => rows()[Math.min(selected(), rows().length - 1)];
|
|
849
881
|
let scroll;
|
|
850
882
|
createEffect(() => {
|
|
@@ -864,7 +896,7 @@ function TreePanel(props) {
|
|
|
864
896
|
const stopSelected = () => {
|
|
865
897
|
const row = current();
|
|
866
898
|
if (row)
|
|
867
|
-
confirmStop(ctx, courier, location,
|
|
899
|
+
confirmStop(ctx, courier, location, view.root, {
|
|
868
900
|
sessionID: row.sessionID,
|
|
869
901
|
title: row.title,
|
|
870
902
|
under: reach(rows(), row.sessionID) - 1
|
|
@@ -920,7 +952,7 @@ function TreePanel(props) {
|
|
|
920
952
|
run: () => input.close()
|
|
921
953
|
}]
|
|
922
954
|
}));
|
|
923
|
-
const root = () => parts(rows(),
|
|
955
|
+
const root = () => parts(rows(), view.root);
|
|
924
956
|
const rowColor = (row) => {
|
|
925
957
|
if (row.state === "waiting")
|
|
926
958
|
return theme.text.feedback.warning.base;
|
|
@@ -1010,6 +1042,7 @@ function TreePanel(props) {
|
|
|
1010
1042
|
_$insert(_el$17, indent, _el$18);
|
|
1011
1043
|
_$insert(_el$17, () => chosen() ? "\u25B8 " : " ", _el$18);
|
|
1012
1044
|
_$insert(_el$17, () => row.title, _el$18);
|
|
1045
|
+
_$insert(_el$17, () => row.sessionID === input.sessionID ? " (on screen)" : "", _el$18);
|
|
1013
1046
|
_$insert(_el$19, () => statusLine(row));
|
|
1014
1047
|
_$insert(_el$16, _$createComponent(For, {
|
|
1015
1048
|
get each() {
|
|
@@ -1066,6 +1099,23 @@ function addTreePanel(ctx) {
|
|
|
1066
1099
|
location
|
|
1067
1100
|
} = courierOf(ctx);
|
|
1068
1101
|
const [changed, stopListening] = changes(ctx, PANEL_CHANGES, PANEL_SETTLE_MS);
|
|
1102
|
+
let following;
|
|
1103
|
+
let attempt = 0;
|
|
1104
|
+
const viewOf = (sessionID) => {
|
|
1105
|
+
const here = following?.at === sessionID ? following : undefined;
|
|
1106
|
+
const refocus = here?.refocus === true;
|
|
1107
|
+
if (here)
|
|
1108
|
+
here.refocus = false;
|
|
1109
|
+
return {
|
|
1110
|
+
root: here?.root ?? sessionID,
|
|
1111
|
+
tree: here?.tree,
|
|
1112
|
+
refocus,
|
|
1113
|
+
note: (fields) => {
|
|
1114
|
+
if (here && following === here)
|
|
1115
|
+
Object.assign(here, fields);
|
|
1116
|
+
}
|
|
1117
|
+
};
|
|
1118
|
+
};
|
|
1069
1119
|
const release = ctx.ui.slot({
|
|
1070
1120
|
append: "session.panel",
|
|
1071
1121
|
render: (input) => _$createComponent(Show, {
|
|
@@ -1076,6 +1126,9 @@ function addTreePanel(ctx) {
|
|
|
1076
1126
|
return _$createComponent(TreePanel, {
|
|
1077
1127
|
ctx,
|
|
1078
1128
|
input,
|
|
1129
|
+
get view() {
|
|
1130
|
+
return viewOf(input.sessionID);
|
|
1131
|
+
},
|
|
1079
1132
|
courier,
|
|
1080
1133
|
location,
|
|
1081
1134
|
changed
|
|
@@ -1083,13 +1136,98 @@ function addTreePanel(ctx) {
|
|
|
1083
1136
|
}
|
|
1084
1137
|
})
|
|
1085
1138
|
});
|
|
1086
|
-
const
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1139
|
+
const show = (from, at, root, tree) => {
|
|
1140
|
+
following = {
|
|
1141
|
+
...from,
|
|
1142
|
+
root,
|
|
1143
|
+
at,
|
|
1144
|
+
to: undefined,
|
|
1145
|
+
tree,
|
|
1146
|
+
refocus: !from.focused
|
|
1147
|
+
};
|
|
1148
|
+
ctx.ui.panel.open(PANEL, {
|
|
1149
|
+
presentation: from.presentation
|
|
1150
|
+
});
|
|
1151
|
+
};
|
|
1152
|
+
const treeOf = (sessionID) => courier.tree({
|
|
1153
|
+
sessionID
|
|
1154
|
+
}, {
|
|
1155
|
+
location: location(sessionID)
|
|
1156
|
+
}).catch(() => {
|
|
1157
|
+
return;
|
|
1158
|
+
});
|
|
1159
|
+
const follow = (from, sessionID) => {
|
|
1160
|
+
const token = ++attempt;
|
|
1161
|
+
const under = (tree) => tree?.rows.some((row) => row.sessionID === sessionID) === true;
|
|
1162
|
+
Object.assign(from, {
|
|
1163
|
+
at: undefined,
|
|
1164
|
+
to: sessionID
|
|
1165
|
+
});
|
|
1166
|
+
if (sessionID === from.root || under(from.tree))
|
|
1167
|
+
return show(from, sessionID, from.root, from.tree);
|
|
1168
|
+
(async () => {
|
|
1169
|
+
const inside = await treeOf(from.root);
|
|
1170
|
+
const own = under(inside) ? undefined : await treeOf(sessionID);
|
|
1171
|
+
if (token !== attempt || following !== from)
|
|
1172
|
+
return;
|
|
1173
|
+
if (under(inside))
|
|
1174
|
+
show(from, sessionID, from.root, inside);
|
|
1175
|
+
else if (own?.rows.length)
|
|
1176
|
+
show(from, sessionID, sessionID, own);
|
|
1177
|
+
})();
|
|
1178
|
+
};
|
|
1179
|
+
const hide = (from) => {
|
|
1180
|
+
++attempt;
|
|
1181
|
+
Object.assign(from, {
|
|
1182
|
+
at: undefined,
|
|
1183
|
+
to: undefined
|
|
1184
|
+
});
|
|
1185
|
+
};
|
|
1186
|
+
const stopFollowing = createRoot((dispose) => {
|
|
1187
|
+
createEffect(() => {
|
|
1188
|
+
const route = ctx.ui.router.current();
|
|
1189
|
+
const sessionID = route.type === "session" ? route.sessionID : undefined;
|
|
1190
|
+
const shown2 = ctx.ui.panel.current();
|
|
1191
|
+
untrack(() => {
|
|
1192
|
+
if (!following || shown2 && shown2.sessionID === sessionID || !following.at && following.to === sessionID)
|
|
1193
|
+
return;
|
|
1194
|
+
if (!shown2 && sessionID && following.at === sessionID)
|
|
1195
|
+
following = undefined;
|
|
1196
|
+
else if (!sessionID)
|
|
1197
|
+
hide(following);
|
|
1198
|
+
else
|
|
1199
|
+
follow(following, sessionID);
|
|
1092
1200
|
});
|
|
1201
|
+
});
|
|
1202
|
+
createEffect(on(changed, () => untrack(() => {
|
|
1203
|
+
const route = ctx.ui.router.current();
|
|
1204
|
+
if (!following || following.at || route.type !== "session" || following.to !== route.sessionID || ctx.ui.panel.current())
|
|
1205
|
+
return;
|
|
1206
|
+
following.focused = false;
|
|
1207
|
+
follow(following, route.sessionID);
|
|
1208
|
+
}), {
|
|
1209
|
+
defer: true
|
|
1210
|
+
}));
|
|
1211
|
+
return dispose;
|
|
1212
|
+
});
|
|
1213
|
+
const openPanel = () => {
|
|
1214
|
+
const route = ctx.ui.router.current();
|
|
1215
|
+
const before = following;
|
|
1216
|
+
if (route.type === "session" && following?.at !== route.sessionID)
|
|
1217
|
+
following = {
|
|
1218
|
+
root: route.sessionID,
|
|
1219
|
+
at: route.sessionID,
|
|
1220
|
+
presentation: "panel",
|
|
1221
|
+
focused: true
|
|
1222
|
+
};
|
|
1223
|
+
if (ctx.ui.panel.open(PANEL))
|
|
1224
|
+
return;
|
|
1225
|
+
following = before;
|
|
1226
|
+
ctx.ui.toast.show({
|
|
1227
|
+
title: "courier",
|
|
1228
|
+
message: "Open a session first",
|
|
1229
|
+
variant: "warning"
|
|
1230
|
+
});
|
|
1093
1231
|
};
|
|
1094
1232
|
const stopUnder = async (target) => {
|
|
1095
1233
|
const route = ctx.ui.router.current();
|
|
@@ -1153,6 +1291,7 @@ function addTreePanel(ctx) {
|
|
|
1153
1291
|
}]
|
|
1154
1292
|
}));
|
|
1155
1293
|
return () => {
|
|
1294
|
+
stopFollowing();
|
|
1156
1295
|
stopListening();
|
|
1157
1296
|
release();
|
|
1158
1297
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-courier",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Multi-session orchestration for OpenCode V2: spawn child sessions, be woken by them. No polling.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Ivo Pogace <71701666+ivopogace@users.noreply.github.com>",
|
|
@@ -56,12 +56,12 @@
|
|
|
56
56
|
"test:e2e": "e2e/run.sh"
|
|
57
57
|
},
|
|
58
58
|
"peerDependencies": {
|
|
59
|
-
"@opencode/plugin": "2.0.
|
|
59
|
+
"@opencode/plugin": "2.0.29",
|
|
60
60
|
"effect": "4.0.1"
|
|
61
61
|
},
|
|
62
62
|
"devDependencies": {
|
|
63
|
-
"@opencode/plugin": "2.0.
|
|
64
|
-
"@opencode/theme": "2.0.
|
|
63
|
+
"@opencode/plugin": "2.0.29",
|
|
64
|
+
"@opencode/theme": "2.0.29",
|
|
65
65
|
"@opentui/core": "0.5.17",
|
|
66
66
|
"@opentui/solid": "0.5.17",
|
|
67
67
|
"@types/bun": "1.3.14",
|