@op1/threads 0.1.4 → 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/README.md +22 -6
- package/index.ts +6 -1
- package/package.json +6 -1
- package/skills/managed-sessions/SKILL.md +11 -4
- package/src/permissions.ts +32 -0
- package/src/rpc.ts +6 -0
- package/src/threads.ts +99 -20
- package/tui.ts +50 -7
package/README.md
CHANGED
|
@@ -47,7 +47,7 @@ The native tool names use namespace `threads` and individual names `spawn`, `lis
|
|
|
47
47
|
|
|
48
48
|
| Tool | Input | Result |
|
|
49
49
|
| --- | --- | --- |
|
|
50
|
-
| `threads_spawn` | `{ key, title, directory, task }` | Worker view |
|
|
50
|
+
| `threads_spawn` | `{ key, title, directory, task, agent? }` | Worker view |
|
|
51
51
|
| `threads_list` | `{}` | `{ workers: WorkerView[] }` |
|
|
52
52
|
| `threads_send` | `{ workerID, key, text }` | `{ workerID, messageID }` |
|
|
53
53
|
| `threads_interrupt` | `{ workerID }` | Worker view |
|
|
@@ -56,21 +56,31 @@ The native tool names use namespace `threads` and individual names `spawn`, `lis
|
|
|
56
56
|
|
|
57
57
|
All fields are strings except `evidence`, which is an array of strings. Verdicts are `PASS`, `PASS WITH NOTES`, `FAIL`, and `INCONCLUSIVE`. Each tool returns JSON in native `content` and the same value in `output`.
|
|
58
58
|
|
|
59
|
-
`directory` must exist and be absolute.
|
|
59
|
+
`directory` must exist and be absolute. Without `agent`, the worker inherits the coordinator's active agent and resolved model, with agent permissions followed by session permissions.
|
|
60
|
+
|
|
61
|
+
Set `agent` to use a configured profile, such as `agent: "vera-core"` for a VERA workstream or `agent: "vera-auditor-readonly"` for an independent review. The plugin resolves the profile in the assigned directory. OpenCode supplies its system prompt and step limit. The profile's model and variant take precedence; a profile without a model inherits the coordinator's resolved model. Explicit selection supports `primary`, `all`, and `subagent` profiles on OpenCode 2.0.3.
|
|
62
|
+
|
|
63
|
+
Explicit selection requires the caller's ordered agent and session rules to allow `subagent` for that exact agent ID. A matching `deny` or `ask` rejects the request before creation. OpenCode's plugin API cannot request approval for an input-dependent agent ID.
|
|
64
|
+
|
|
65
|
+
A selected worker uses its profile's permissions. Parent session `deny` and `ask` rules at creation become hard denials on the worker, and parent allows are not copied. This conservative rule also drops parent allow exceptions that follow a denial. It prevents inherited permissions from relaxing a read-only profile. Each managed worker receives one explicit grant for `threads_report`, whose handler verifies ownership. Native subagents retain their own profiles and inherit those session restrictions. Omitting `agent` keeps the existing inheritance behavior.
|
|
60
66
|
|
|
61
67
|
Tool identity comes from the calling session. Only the owning coordinator can send, interrupt, or hide a worker. Only the exact original top-level worker can report. Native subagents and managed workers cannot spawn managed workers. Native `subagent` remains available.
|
|
62
68
|
|
|
63
69
|
## Identity and retries
|
|
64
70
|
|
|
65
|
-
The coordinator ID and spawn key determine the worker ID. Worker metadata contains `opThreads` with exactly `workerID`, `coordinatorID`, `key`, `fingerprint`, `initialMessageID`, and `reportMessageID`. There is no native `parentID`.
|
|
71
|
+
The coordinator ID and spawn key determine the worker ID. Worker metadata contains `opThreads` with exactly `workerID`, `coordinatorID`, `key`, `fingerprint`, `initialMessageID`, and `reportMessageID`. Explicit-role workers also carry `opThreadsRole: true`. There is no native `parentID`.
|
|
72
|
+
|
|
73
|
+
The first create includes both message IDs. Identical spawn retries reuse the original session and initial message ID, even if the profile configuration has changed. They do not reset its agent or model. Changing the title, directory, task, or explicit agent under that key is an error. Requests that omit `agent` retain their pre-role fingerprint. Startup never replays initial prompts.
|
|
66
74
|
|
|
67
|
-
The
|
|
75
|
+
The initial prompt hook resolves a selected profile after OpenCode loads the assigned directory's configuration. It rechecks the caller's role authorization and selects the profile's model before admitting the task. If the profile is unavailable, admission fails without running the task. The indexed worker remains recoverable: fix the profile configuration and retry the identical request. Until initialization completes, managed follow-ups and direct prompts are rejected. Initialization is recorded after native admission; a retry repairs that record if interrupted between the two writes.
|
|
68
76
|
|
|
69
77
|
Send keys are scoped to the worker and determine a stable message ID. A retry with different text is rejected. Each worker has one task and one terminal report. Identical report retries return the original report; conflicting reports are rejected. Send clarifications within the existing task, and use a new spawn key for new work. The persisted report view is keyed by the original report message ID, so recreating a deleted worker cannot inherit an old verdict.
|
|
70
78
|
|
|
71
79
|
## Worker views and limits
|
|
72
80
|
|
|
73
|
-
`WorkerView` contains `workerID`, `coordinatorID`, `key`, `title`, `directory`, `outcome`, `report`, and `hidden`. `
|
|
81
|
+
`WorkerView` contains `workerID`, `coordinatorID`, `key`, `title`, `directory`, `agent`, `model`, `outcome`, `report`, and `hidden`. `agent` and `model` reflect the native session's saved selection, or `null` if unset. A model contains `providerID`, `id`, and an optional `variant`.
|
|
82
|
+
|
|
83
|
+
`outcome` is the native last execution outcome, or `null` before one exists. It is not current activity. Native tabs display current busy, attention, and unread state.
|
|
74
84
|
|
|
75
85
|
`report` is the explicit worker claim, or `null`. Native `succeeded` means the agent loop completed, not that the assigned task passed.
|
|
76
86
|
|
|
@@ -82,6 +92,8 @@ Plugin option `maxWorkers` defaults to 4 and accepts integers from 1 through 32.
|
|
|
82
92
|
|
|
83
93
|
The terminal synchronizes workers before opening native tabs without changing focus. A TUI memory index survives plugin reloads and respects manually closed tabs. `/threads` explicitly reopens workers for open coordinator tabs. A new TUI recovers workers from durable storage. Closing the TUI does not interrupt workers.
|
|
84
94
|
|
|
95
|
+
Managed tabs use saved title prefixes: `[Main]` for the coordinator and `[Worker]` for each managed worker. Labels appear when the TUI discovers the relationship, including existing threads. The rest of the title stays editable; renaming a managed conversation reapplies its role prefix. Unrelated sessions keep their titles. OpenCode's native tab API has no separate badge field, so the prefixes also appear in session history.
|
|
96
|
+
|
|
85
97
|
Workers with `PASS` or `PASS WITH NOTES` reports hide automatically once idle. This also applies to reports saved before upgrading. Unreported workers and `FAIL` or `INCONCLUSIVE` reports stay visible. The selected tab, running workers, and tabs needing input stay open until they are inactive.
|
|
86
98
|
|
|
87
99
|
The coordinator can call `threads_hide` when a worker is no longer needed. Hiding preserves the conversation and report, survives restarts, and does not free an admission slot. `/threads` restores hidden workers and keeps them visible for inspection. A valid `threads_send` follow-up also restores its worker. Visibility overrides belong to the original report message ID, so recreating a deleted worker cannot inherit its hidden state.
|
|
@@ -90,7 +102,7 @@ Reports reach the coordinator through silent synthetic messages. They remain ava
|
|
|
90
102
|
|
|
91
103
|
All open native root-session tabs are automatically grouped by OpenCode project ID, including sessions not managed by this plugin. Projects follow their first appearance in the current tab order. Worktrees with the same project ID stay together.
|
|
92
104
|
|
|
93
|
-
Within each project, running sessions
|
|
105
|
+
Within each project, running sessions and sessions waiting for input come before idle sessions. Tabs with the same priority keep their relative order. Selecting an idle tab does not reorder it. Native tab order is shared between terminals in the same directory, so sorting by each terminal's selection would make them repeatedly undo each other's moves. Idle is a display priority, not a completion verdict. Each tab with unloaded project metadata stays in its own group until that metadata becomes available. Reconciliation moves only out-of-order tabs, without changing focus or closing and reopening them to reorder. Native tabs do not support divider rows.
|
|
94
106
|
|
|
95
107
|
The RPC definition is `ThreadsRpc` in `src/rpc.ts`, with ID `threads`. `snapshot` is read-only. The user-invoked `restore` method clears hidden state for the supplied coordinators' workers. Both methods accept and return:
|
|
96
108
|
|
|
@@ -107,6 +119,10 @@ Run `bun run typecheck`, `bun test`, and `bun run verify:live`. The live check r
|
|
|
107
119
|
|
|
108
120
|
Run `bun run verify:tabs` to verify project grouping across real git worktrees, activity-based ordering, permission prompts, completed and resumed workers, focus preservation, and TUI reopening.
|
|
109
121
|
|
|
122
|
+
Run `bun run verify:roles` to verify named profiles against the native server and deterministic model endpoint. It checks actual system prompts, model variants, native delegation, read-only execution, reporting, and role-aware retries.
|
|
123
|
+
|
|
124
|
+
Run `bun run verify:idle-tabs` to open two native terminals on different idle sessions in the same directory and verify that their shared tab order stays stable.
|
|
125
|
+
|
|
110
126
|
Pass an extracted package directory to test the release artifact: `bun run verify:live /absolute/path/to/package`.
|
|
111
127
|
|
|
112
128
|
The native session and plugin index are separate writes. A crash after session creation but before indexing requires an explicit identical spawn retry. A crash after native report admission but before saving the report view requires an explicit report retry; the native coordinator notification remains canonical and is not duplicated. There is no custom outbox or startup task replay.
|
package/index.ts
CHANGED
|
@@ -15,6 +15,11 @@ export default Plugin.define({
|
|
|
15
15
|
.default(4)
|
|
16
16
|
.parse(ctx.options.maxWorkers);
|
|
17
17
|
const workers = threads(ctx, limit);
|
|
18
|
+
await ctx.session.hook("prompt", (event) => {
|
|
19
|
+
return workers.preparePrompt(
|
|
20
|
+
event.sessionID, event.messageID, event.metadata?.opThreadsCallerAgent,
|
|
21
|
+
);
|
|
22
|
+
});
|
|
18
23
|
const models = new Map<
|
|
19
24
|
SessionContext["sessionID"],
|
|
20
25
|
SessionContext["model"]
|
|
@@ -38,7 +43,7 @@ export default Plugin.define({
|
|
|
38
43
|
editor.add({
|
|
39
44
|
name: "spawn",
|
|
40
45
|
description:
|
|
41
|
-
"Delegate a task to a top-level worker in an existing absolute directory.
|
|
46
|
+
"Delegate a task to a top-level worker in an existing absolute directory. Set agent to a configured profile (for example vera-core); its prompt, model preference, and permissions apply. Omit agent to inherit your active agent and model. Native subagent remains available within the selected profile's permissions and task's delegation limits. Workers cannot call threads_spawn. Reuse key only for identical requests, including agent.",
|
|
42
47
|
input: Spawn,
|
|
43
48
|
output: WorkerView,
|
|
44
49
|
options: {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@op1/threads",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"description": "Visible top-level worker sessions for OpenCode V2, with native tabs and durable reports.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.ts",
|
|
@@ -25,6 +25,8 @@
|
|
|
25
25
|
"typecheck": "tsc --noEmit",
|
|
26
26
|
"test": "bun test",
|
|
27
27
|
"verify:live": "uv run --with pyte python scripts/verify-live.py",
|
|
28
|
+
"verify:roles": "python3 scripts/verify-roles.py",
|
|
29
|
+
"verify:idle-tabs": "uv run --with pyte python scripts/verify-idle-tabs.py",
|
|
28
30
|
"verify:tabs": "uv run --with pyte python scripts/verify-tab-groups.py"
|
|
29
31
|
},
|
|
30
32
|
"dependencies": {
|
|
@@ -32,6 +34,9 @@
|
|
|
32
34
|
"@opencode/schema": "2.0.3",
|
|
33
35
|
"zod": "4.1.8"
|
|
34
36
|
},
|
|
37
|
+
"peerDependencies": {
|
|
38
|
+
"solid-js": ">=1.9.0"
|
|
39
|
+
},
|
|
35
40
|
"devDependencies": {
|
|
36
41
|
"@types/bun": "1.4.0",
|
|
37
42
|
"solid-js": "1.9.9",
|
|
@@ -16,10 +16,15 @@ Both the parent and managed workers may use native `subagent` when useful. Deleg
|
|
|
16
16
|
|
|
17
17
|
1. Confirm the `threads_spawn` tool is available. If it is unavailable, use native `subagent` or report that the plugin needs activation. Do not substitute a hidden `opencode run` process.
|
|
18
18
|
2. Assign an existing absolute directory and a stable task key.
|
|
19
|
-
3.
|
|
20
|
-
4.
|
|
19
|
+
3. If the tool schema supports `agent`, select a configured profile explicitly. Use `agent: "vera-core"` for a VERA workstream that owns integration and may delegate. Use a specialist profile for a leaf task or read-only review. Omitting `agent` inherits the caller's active agent and model. Older plugin versions always inherit them.
|
|
20
|
+
4. Call `threads_spawn` with `key`, `title`, `directory`, `task`, and the selected `agent`. Include the goal, scope, relevant context, constraints, delegation allowance, acceptance criteria, verification commands, and expected report in `task`.
|
|
21
|
+
5. Save the returned worker session ID and inspect its returned agent and model when present.
|
|
21
22
|
|
|
22
|
-
An identical spawn key retries the original admission. Different work requires a new key. A
|
|
23
|
+
An identical spawn key retries the original admission. Different work or a different agent requires a new key. A selected profile supplies its real system prompt, model preference, and permissions in the assigned directory. A profile without a model inherits the caller's resolved model. It has a separate conversation, so include all context it needs in the task brief. Mentioning a role in the brief does not select that profile.
|
|
24
|
+
|
|
25
|
+
Explicit role selection requires an `allow` for `subagent` on that role ID. Selected workers carry parent session `deny` and `ask` rules as hard denials; parent allow exceptions do not reopen them. Read-only workers can still call their ownership-checked `threads_report` tool.
|
|
26
|
+
|
|
27
|
+
If the selected profile is unavailable, fix its configuration and retry the identical spawn request. The indexed worker stays uninitialized until that retry succeeds. Do not use `threads_send` to start it.
|
|
23
28
|
|
|
24
29
|
## Write the delegation allowance
|
|
25
30
|
|
|
@@ -27,7 +32,7 @@ The parent writes `task`; the plugin appends worker instructions. Give workers t
|
|
|
27
32
|
|
|
28
33
|
> You may work directly or use native `subagent` for bounded tasks and reviews when useful. Pass your scope and constraints to subagents, review their results, and resolve outstanding work before calling `threads_report` yourself.
|
|
29
34
|
|
|
30
|
-
|
|
35
|
+
Honor the selected profile's restrictions. A specialist that denies native delegation stays a leaf, and a read-only profile stays read-only. For a delegation-capable profile, add a no-delegation restriction only for a task-specific reason or an explicit user constraint, and state the reason. Being a managed worker or having bounded scope does not by itself make the worker a leaf. To limit managed-thread nesting, say `Do not call threads_spawn` rather than `No children`.
|
|
31
36
|
|
|
32
37
|
## Coordinate
|
|
33
38
|
|
|
@@ -46,4 +51,6 @@ A worker report is a claim to review. Run the protocol's independent verificatio
|
|
|
46
51
|
|
|
47
52
|
## Visibility
|
|
48
53
|
|
|
54
|
+
Managed tab titles start with `[Main]` for the coordinator or `[Worker]` for the worker. The TUI applies these prefixes to saved titles while preserving the rest of the name.
|
|
55
|
+
|
|
49
56
|
The TUI plugin opens managed workers as ordinary tabs without changing focus. Workers with `PASS` or `PASS WITH NOTES` reports hide automatically once inactive. Failed, inconclusive, and unreported workers stay visible until the coordinator hides them. Running, selected, and attention-needed tabs stay open. Use `/threads` to restore hidden tabs for inspection. Sending a valid follow-up also restores that worker. Reports are delivered silently to the coordinator and remain available through `threads_list`.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { Permission } from "@opencode/schema/permission";
|
|
2
|
+
|
|
3
|
+
function matches(pattern: string, value: string) {
|
|
4
|
+
const expression = pattern
|
|
5
|
+
.replaceAll("\\", "/")
|
|
6
|
+
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
|
7
|
+
.replaceAll("*", ".*")
|
|
8
|
+
.replaceAll("?", ".");
|
|
9
|
+
const normalized = value.replaceAll("\\", "/");
|
|
10
|
+
const match = new RegExp(
|
|
11
|
+
`^${expression}$`,
|
|
12
|
+
process.platform === "win32" ? "is" : "s",
|
|
13
|
+
).exec(normalized);
|
|
14
|
+
return match?.[0] === normalized;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function delegationEffect(
|
|
18
|
+
rules: Permission.Ruleset,
|
|
19
|
+
agentID: string,
|
|
20
|
+
): Permission.Effect {
|
|
21
|
+
return rules.findLast((rule) =>
|
|
22
|
+
matches(rule.action, "subagent") && matches(rule.resource, agentID)
|
|
23
|
+
)?.effect ?? "ask";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function requireDelegation(rules: Permission.Ruleset, agentID: string) {
|
|
27
|
+
if (delegationEffect(rules, agentID) !== "allow") {
|
|
28
|
+
throw new Error(
|
|
29
|
+
`Spawning agent "${agentID}" requires an explicit subagent allow for that agent ID; deny or ask cannot authorize managed spawning.`,
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
}
|
package/src/rpc.ts
CHANGED
|
@@ -15,6 +15,12 @@ export const WorkerView = z.object({
|
|
|
15
15
|
key: z.string(),
|
|
16
16
|
title: z.string(),
|
|
17
17
|
directory: z.string(),
|
|
18
|
+
agent: z.string().nullable(),
|
|
19
|
+
model: z.object({
|
|
20
|
+
providerID: z.string(),
|
|
21
|
+
id: z.string(),
|
|
22
|
+
variant: z.string().optional(),
|
|
23
|
+
}).nullable(),
|
|
18
24
|
outcome: z.enum(["succeeded", "failed", "interrupted"]).nullable(),
|
|
19
25
|
report: Report.nullable(),
|
|
20
26
|
hidden: z.boolean(),
|
package/src/threads.ts
CHANGED
|
@@ -4,9 +4,11 @@ import { isAbsolute } from "node:path";
|
|
|
4
4
|
import type { Plugin } from "@opencode/plugin";
|
|
5
5
|
import type { SessionContext } from "@opencode/plugin/promise/session";
|
|
6
6
|
import type { ToolContext } from "@opencode/plugin/promise/tool";
|
|
7
|
+
import type { Permission } from "@opencode/schema/permission";
|
|
7
8
|
import { Session } from "@opencode/schema/session";
|
|
8
9
|
import { SessionMessage } from "@opencode/schema/session-message";
|
|
9
10
|
import { z } from "zod";
|
|
11
|
+
import { requireDelegation } from "./permissions";
|
|
10
12
|
import { Report, WorkerView } from "./rpc";
|
|
11
13
|
|
|
12
14
|
const sessionID = z.string().transform((value) => Session.ID.make(value));
|
|
@@ -33,6 +35,9 @@ export const Spawn = z
|
|
|
33
35
|
title: z.string().min(1),
|
|
34
36
|
directory: z.string().min(1),
|
|
35
37
|
task: z.string().min(1),
|
|
38
|
+
agent: z.string().min(1).optional().describe(
|
|
39
|
+
"Configured agent ID in the worker's directory. Omit to inherit the caller's agent and model.",
|
|
40
|
+
),
|
|
36
41
|
})
|
|
37
42
|
.strict();
|
|
38
43
|
export const Send = z
|
|
@@ -49,7 +54,12 @@ const digest = (parts: string[]) =>
|
|
|
49
54
|
export const workerIdentity = (coordinatorID: string, key: string) =>
|
|
50
55
|
Session.ID.make(`ses_${digest([coordinatorID, key]).slice(0, 32)}`);
|
|
51
56
|
export const fingerprint = (input: z.infer<typeof Spawn>) =>
|
|
52
|
-
digest([
|
|
57
|
+
digest([
|
|
58
|
+
input.title,
|
|
59
|
+
input.directory,
|
|
60
|
+
input.task,
|
|
61
|
+
...(input.agent === undefined ? [] : [input.agent]),
|
|
62
|
+
]);
|
|
53
63
|
|
|
54
64
|
const locks = new Map<string, Promise<void>>();
|
|
55
65
|
export async function serialized<T>(
|
|
@@ -96,6 +106,18 @@ export function threads(
|
|
|
96
106
|
`reports/${link.workerID}/${link.reportMessageID}`;
|
|
97
107
|
const visibilityKey = (link: z.infer<typeof Link>) =>
|
|
98
108
|
`visibility/${link.workerID}/${link.reportMessageID}`;
|
|
109
|
+
const initializedKey = (link: z.infer<typeof Link>) =>
|
|
110
|
+
`initialized/${link.workerID}/${link.initialMessageID}`;
|
|
111
|
+
|
|
112
|
+
async function initialized(session: NativeSession, link: z.infer<typeof Link>) {
|
|
113
|
+
return session.metadata?.opThreadsRole !== true ||
|
|
114
|
+
await ctx.storage.get(initializedKey(link)) === true;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function callerPermissions(session: NativeSession, agentID: string) {
|
|
118
|
+
const agent = await ctx.agent.get({ agentID, location: session.location });
|
|
119
|
+
return [...agent.data.permissions, ...(session.permissions ?? [])];
|
|
120
|
+
}
|
|
99
121
|
|
|
100
122
|
async function view(
|
|
101
123
|
session: NativeSession,
|
|
@@ -110,6 +132,8 @@ export function threads(
|
|
|
110
132
|
key: link.key,
|
|
111
133
|
title: session.title ?? link.key,
|
|
112
134
|
directory: session.location.directory,
|
|
135
|
+
agent: session.agent ?? null,
|
|
136
|
+
model: session.model ?? null,
|
|
113
137
|
outcome: session.outcome ?? null,
|
|
114
138
|
report,
|
|
115
139
|
hidden:
|
|
@@ -141,6 +165,7 @@ export function threads(
|
|
|
141
165
|
throw error;
|
|
142
166
|
await ctx.storage.remove(reportKey(link));
|
|
143
167
|
await ctx.storage.remove(visibilityKey(link));
|
|
168
|
+
await ctx.storage.remove(initializedKey(link));
|
|
144
169
|
await ctx.storage.remove(entry.key);
|
|
145
170
|
continue;
|
|
146
171
|
}
|
|
@@ -161,6 +186,34 @@ export function threads(
|
|
|
161
186
|
|
|
162
187
|
return {
|
|
163
188
|
list,
|
|
189
|
+
async preparePrompt(actor: string, messageID: string, callerAgent: unknown) {
|
|
190
|
+
const session = await ctx.session.get({ sessionID: actor });
|
|
191
|
+
if (session.metadata?.opThreadsRole !== true) return;
|
|
192
|
+
const recorded = Link.parse(session.metadata.opThreads);
|
|
193
|
+
if (session.parentID !== undefined || recorded.workerID !== session.id) return;
|
|
194
|
+
const link = workerLink(session);
|
|
195
|
+
if (await initialized(session, link)) return;
|
|
196
|
+
if (link.initialMessageID !== messageID || session.agent === undefined) {
|
|
197
|
+
throw new Error(
|
|
198
|
+
"Worker initialization is pending. Retry the original threads_spawn request.",
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
const coordinator = await ctx.session.get({ sessionID: link.coordinatorID });
|
|
202
|
+
requireDelegation(
|
|
203
|
+
await callerPermissions(coordinator, z.string().min(1).parse(callerAgent)),
|
|
204
|
+
session.agent,
|
|
205
|
+
);
|
|
206
|
+
const agent = await ctx.agent.get({
|
|
207
|
+
agentID: session.agent,
|
|
208
|
+
location: session.location,
|
|
209
|
+
});
|
|
210
|
+
if (agent.data.model) {
|
|
211
|
+
await ctx.session.switchModel({
|
|
212
|
+
sessionID: session.id,
|
|
213
|
+
model: agent.data.model,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
},
|
|
164
217
|
async hide(actor: string, input: z.infer<typeof WorkerTarget>) {
|
|
165
218
|
const { session, link } = await owned(actor, input.workerID);
|
|
166
219
|
await ctx.storage.set(visibilityKey(link), true);
|
|
@@ -199,9 +252,17 @@ export function threads(
|
|
|
199
252
|
throw new Error("directory must be an existing absolute directory");
|
|
200
253
|
}
|
|
201
254
|
const workerID = workerIdentity(actor, input.key);
|
|
255
|
+
let session = await ctx.session.get({ sessionID: workerID }).catch(
|
|
256
|
+
(error: unknown) => {
|
|
257
|
+
const missing = MissingSession.safeParse(error);
|
|
258
|
+
if (!missing.success || missing.data.sessionID !== workerID)
|
|
259
|
+
throw error;
|
|
260
|
+
return undefined;
|
|
261
|
+
},
|
|
262
|
+
);
|
|
202
263
|
const existing = await list(actor);
|
|
203
264
|
if (
|
|
204
|
-
!
|
|
265
|
+
!session &&
|
|
205
266
|
existing.filter(
|
|
206
267
|
(worker) =>
|
|
207
268
|
!worker.report &&
|
|
@@ -211,10 +272,6 @@ export function threads(
|
|
|
211
272
|
) {
|
|
212
273
|
throw new Error(`Coordinator worker limit reached (${limit})`);
|
|
213
274
|
}
|
|
214
|
-
const agent = await ctx.agent.get({
|
|
215
|
-
agentID: runtime.agent,
|
|
216
|
-
location: coordinator.location,
|
|
217
|
-
});
|
|
218
275
|
const proposed = Link.parse({
|
|
219
276
|
workerID,
|
|
220
277
|
coordinatorID: actor,
|
|
@@ -223,18 +280,31 @@ export function threads(
|
|
|
223
280
|
initialMessageID: SessionMessage.ID.create(),
|
|
224
281
|
reportMessageID: SessionMessage.ID.create(),
|
|
225
282
|
});
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
283
|
+
if (!session) {
|
|
284
|
+
const inherited = await callerPermissions(coordinator, runtime.agent);
|
|
285
|
+
if (input.agent !== undefined) requireDelegation(inherited, input.agent);
|
|
286
|
+
session = await ctx.session.create({
|
|
287
|
+
id: workerID,
|
|
288
|
+
title: input.title,
|
|
289
|
+
location: { directory: input.directory },
|
|
290
|
+
agent: input.agent ?? runtime.agent,
|
|
291
|
+
model: runtime.model,
|
|
292
|
+
permissions: [
|
|
293
|
+
...(input.agent === undefined
|
|
294
|
+
? inherited
|
|
295
|
+
: (coordinator.permissions ?? [])
|
|
296
|
+
.filter((rule) => rule.effect !== "allow")
|
|
297
|
+
.map((rule) => (
|
|
298
|
+
{ ...rule, effect: "deny" } satisfies Permission.Rule
|
|
299
|
+
))),
|
|
300
|
+
{ action: "threads_report", resource: "*", effect: "allow" },
|
|
301
|
+
],
|
|
302
|
+
metadata: {
|
|
303
|
+
opThreads: proposed,
|
|
304
|
+
...(input.agent === undefined ? {} : { opThreadsRole: true }),
|
|
305
|
+
},
|
|
306
|
+
});
|
|
307
|
+
}
|
|
238
308
|
const link = workerLink(session);
|
|
239
309
|
if (
|
|
240
310
|
link.coordinatorID !== actor ||
|
|
@@ -249,13 +319,22 @@ export function threads(
|
|
|
249
319
|
sessionID: link.workerID,
|
|
250
320
|
id: link.initialMessageID,
|
|
251
321
|
delivery: "queue",
|
|
252
|
-
|
|
322
|
+
metadata: input.agent === undefined
|
|
323
|
+
? undefined
|
|
324
|
+
: { opThreadsCallerAgent: runtime.agent },
|
|
325
|
+
text: `${input.task}\n\nYou are a managed worker assigned to ${input.directory}. Work only within the assigned scope. You may use native subagent for bounded tasks or reviews when useful, within the brief's delegation limits and your permissions. Delegation is optional. Pass relevant context, scope, and constraints to each subagent. Do not call threads_spawn. Review your subagents' results and resolve any outstanding work before reporting. Only you call threads_report with the combined verdict, summary, and evidence; subagents return results to you. Runtime completion alone does not establish task success.`,
|
|
253
326
|
});
|
|
327
|
+
if (input.agent !== undefined) {
|
|
328
|
+
await ctx.storage.set(initializedKey(link), true);
|
|
329
|
+
}
|
|
254
330
|
return view(await ctx.session.get({ sessionID: workerID }));
|
|
255
331
|
});
|
|
256
332
|
},
|
|
257
333
|
async send(actor: string, input: z.infer<typeof Send>) {
|
|
258
|
-
const { link } = await owned(actor, input.workerID);
|
|
334
|
+
const { session, link } = await owned(actor, input.workerID);
|
|
335
|
+
if (!await initialized(session, link)) {
|
|
336
|
+
throw new Error("Worker initialization is pending. Retry the original threads_spawn request.");
|
|
337
|
+
}
|
|
259
338
|
const id = SessionMessage.ID.make(
|
|
260
339
|
`msg_${digest([input.workerID, "send", input.key]).slice(0, 32)}`,
|
|
261
340
|
);
|
package/tui.ts
CHANGED
|
@@ -1,22 +1,28 @@
|
|
|
1
1
|
import { Plugin } from "@opencode/plugin/tui";
|
|
2
|
+
import { createEffect } from "solid-js";
|
|
3
|
+
import { z } from "zod";
|
|
2
4
|
import { ThreadsRpc } from "./src/rpc";
|
|
3
5
|
|
|
6
|
+
const CoordinatorRef = z.object({ coordinatorID: z.string() });
|
|
7
|
+
|
|
4
8
|
export default Plugin.define({
|
|
5
9
|
id: "op-threads",
|
|
6
10
|
setup(ctx) {
|
|
7
11
|
const rpc = ctx.client.rpc(ThreadsRpc);
|
|
8
12
|
const initial: { workerIDs: string[] } = { workerIDs: [] };
|
|
9
13
|
const [seen, updateSeen] = ctx.storage.memory("seen-workers", { initial });
|
|
14
|
+
const closing = new Set<string>();
|
|
10
15
|
let stopped = false;
|
|
11
16
|
let running = false;
|
|
12
17
|
let reopenPending = false;
|
|
13
18
|
let lastError: string | undefined;
|
|
19
|
+
let movingFrom: string | undefined;
|
|
14
20
|
function groupTabs() {
|
|
15
21
|
const tabs = ctx.ui.tabs.list().map((tab) => {
|
|
16
22
|
const projectID = ctx.data.session.get(tab.sessionID)?.projectID;
|
|
17
23
|
return {
|
|
18
24
|
sessionID: tab.sessionID,
|
|
19
|
-
priority: tab.busy || tab.
|
|
25
|
+
priority: tab.busy || tab.attention,
|
|
20
26
|
projectID:
|
|
21
27
|
typeof projectID === "string" && projectID.length > 0
|
|
22
28
|
? projectID
|
|
@@ -38,9 +44,14 @@ export default Plugin.define({
|
|
|
38
44
|
.sort((left, right) => Number(right.priority) - Number(left.priority))
|
|
39
45
|
.map((tab) => tab.sessionID),
|
|
40
46
|
);
|
|
47
|
+
const current = tabs.map((tab) => tab.sessionID);
|
|
48
|
+
const stamp = JSON.stringify(current);
|
|
49
|
+
if (movingFrom === stamp) return;
|
|
50
|
+
movingFrom = undefined;
|
|
41
51
|
for (const [index, sessionID] of ordered.entries()) {
|
|
42
|
-
if (
|
|
43
|
-
if (
|
|
52
|
+
if (current[index] === sessionID) continue;
|
|
53
|
+
if (ctx.ui.tabs.move(sessionID, index)) movingFrom = stamp;
|
|
54
|
+
break;
|
|
44
55
|
}
|
|
45
56
|
}
|
|
46
57
|
async function reconcile(reopen = false) {
|
|
@@ -55,7 +66,14 @@ export default Plugin.define({
|
|
|
55
66
|
const route = ctx.ui.router.current();
|
|
56
67
|
const coordinatorIDs = [
|
|
57
68
|
...new Set([
|
|
58
|
-
...ctx.ui.tabs.list().
|
|
69
|
+
...ctx.ui.tabs.list().flatMap((tab) => {
|
|
70
|
+
const link = CoordinatorRef.safeParse(
|
|
71
|
+
ctx.data.session.get(tab.sessionID)?.metadata?.opThreads,
|
|
72
|
+
);
|
|
73
|
+
return link.success
|
|
74
|
+
? [tab.sessionID, link.data.coordinatorID]
|
|
75
|
+
: [tab.sessionID];
|
|
76
|
+
}),
|
|
59
77
|
...(route.type === "session" ? [route.sessionID] : []),
|
|
60
78
|
]),
|
|
61
79
|
].slice(0, 100);
|
|
@@ -67,6 +85,8 @@ export default Plugin.define({
|
|
|
67
85
|
for (const worker of workers) {
|
|
68
86
|
if (stopped) return;
|
|
69
87
|
const tab = ctx.ui.tabs.list().find((tab) => tab.sessionID === worker.workerID);
|
|
88
|
+
if (closing.has(worker.workerID) && tab) continue;
|
|
89
|
+
const closed = closing.delete(worker.workerID);
|
|
70
90
|
if (
|
|
71
91
|
worker.hidden &&
|
|
72
92
|
!tab?.active &&
|
|
@@ -74,7 +94,10 @@ export default Plugin.define({
|
|
|
74
94
|
!tab?.attention &&
|
|
75
95
|
ctx.data.session.status(worker.workerID) !== "running"
|
|
76
96
|
) {
|
|
77
|
-
if (tab
|
|
97
|
+
if (tab) {
|
|
98
|
+
if (!ctx.ui.tabs.close(worker.workerID)) continue;
|
|
99
|
+
closing.add(worker.workerID);
|
|
100
|
+
}
|
|
78
101
|
if (seen.workerIDs.includes(worker.workerID)) {
|
|
79
102
|
updateSeen((draft) => {
|
|
80
103
|
draft.workerIDs = draft.workerIDs.filter(
|
|
@@ -84,7 +107,7 @@ export default Plugin.define({
|
|
|
84
107
|
}
|
|
85
108
|
continue;
|
|
86
109
|
}
|
|
87
|
-
if (!reopen && seen.workerIDs.includes(worker.workerID)) continue;
|
|
110
|
+
if (!reopen && !closed && seen.workerIDs.includes(worker.workerID)) continue;
|
|
88
111
|
await ctx.data.session.sync(worker.workerID);
|
|
89
112
|
if (
|
|
90
113
|
!stopped &&
|
|
@@ -96,7 +119,23 @@ export default Plugin.define({
|
|
|
96
119
|
});
|
|
97
120
|
}
|
|
98
121
|
}
|
|
99
|
-
if (!stopped && ctx.ui.tabs.enabled())
|
|
122
|
+
if (!stopped && ctx.ui.tabs.enabled()) {
|
|
123
|
+
const roles = new Map<string, "Main" | "Worker">();
|
|
124
|
+
for (const worker of workers) {
|
|
125
|
+
roles.set(worker.coordinatorID, "Main");
|
|
126
|
+
roles.set(worker.workerID, "Worker");
|
|
127
|
+
}
|
|
128
|
+
for (const tab of ctx.ui.tabs.list()) {
|
|
129
|
+
if (stopped) return;
|
|
130
|
+
const role = roles.get(tab.sessionID);
|
|
131
|
+
const session = ctx.data.session.get(tab.sessionID);
|
|
132
|
+
if (!role || !session?.title) continue;
|
|
133
|
+
const title = `[${role}] ${session.title.replace(/^\[(?:Main|Worker)\] /, "")}`;
|
|
134
|
+
if (title === session.title) continue;
|
|
135
|
+
await ctx.client.session.rename({ sessionID: tab.sessionID, title });
|
|
136
|
+
}
|
|
137
|
+
groupTabs();
|
|
138
|
+
}
|
|
100
139
|
} finally {
|
|
101
140
|
running = false;
|
|
102
141
|
if (reopenPending) {
|
|
@@ -127,6 +166,10 @@ export default Plugin.define({
|
|
|
127
166
|
const removeSlot = ctx.ui.slot({
|
|
128
167
|
append: "app",
|
|
129
168
|
render: () => {
|
|
169
|
+
createEffect(() => {
|
|
170
|
+
ctx.ui.tabs.list();
|
|
171
|
+
refresh();
|
|
172
|
+
});
|
|
130
173
|
ctx.keymap.layer(() => ({
|
|
131
174
|
mode: "global",
|
|
132
175
|
commands: [
|