@op1/threads 0.1.5 → 0.1.7
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 -8
- package/index.ts +6 -1
- package/package.json +5 -3
- package/skills/managed-sessions/SKILL.md +9 -4
- package/src/permissions.ts +32 -0
- package/src/rpc.ts +6 -0
- package/src/threads.ts +100 -21
- package/tui.ts +11 -26
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @op1/threads
|
|
2
2
|
|
|
3
|
-
Managed top-level worker sessions for OpenCode 2.0.
|
|
3
|
+
Managed top-level worker sessions for OpenCode 2.0.7. The server entrypoint is `index.ts`; the terminal entrypoint is `tui.ts`.
|
|
4
4
|
|
|
5
5
|
## Install
|
|
6
6
|
|
|
@@ -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.7.
|
|
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
|
|
|
@@ -92,7 +102,7 @@ Reports reach the coordinator through silent synthetic messages. They remain ava
|
|
|
92
102
|
|
|
93
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.
|
|
94
104
|
|
|
95
|
-
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.
|
|
96
106
|
|
|
97
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:
|
|
98
108
|
|
|
@@ -105,10 +115,14 @@ The input accepts at most 100 coordinator IDs. Raw HTTP RPC requests wrap the in
|
|
|
105
115
|
|
|
106
116
|
## Verification and limits
|
|
107
117
|
|
|
108
|
-
Run `bun run typecheck`, `bun test`, and `bun run verify:live`. The live check requires OpenCode 2.0.
|
|
118
|
+
Run `bun run typecheck`, `bun test`, and `bun run verify:live`. The live check requires OpenCode 2.0.7, Python, and `uv`. It starts a separate local server, a deterministic model endpoint, and a terminal process with isolated configuration and data. It verifies actual tool calls, durable messages, permission restrictions, worker limits, deleted-worker cleanup, restart behavior, and native tab visibility, busy state, and focus.
|
|
109
119
|
|
|
110
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.
|
|
111
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
|
+
|
|
112
126
|
Pass an extracted package directory to test the release artifact: `bun run verify:live /absolute/path/to/package`.
|
|
113
127
|
|
|
114
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.7",
|
|
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,11 +25,13 @@
|
|
|
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": {
|
|
31
|
-
"@opencode/plugin": "2.0.
|
|
32
|
-
"@opencode/schema": "2.0.
|
|
33
|
+
"@opencode/plugin": "2.0.7",
|
|
34
|
+
"@opencode/schema": "2.0.7",
|
|
33
35
|
"zod": "4.1.8"
|
|
34
36
|
},
|
|
35
37
|
"peerDependencies": {
|
|
@@ -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
|
|
|
@@ -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
|
);
|
|
@@ -279,7 +358,7 @@ export function threads(
|
|
|
279
358
|
await owned(actor, input.workerID);
|
|
280
359
|
await ctx.session.interrupt({
|
|
281
360
|
sessionID: input.workerID,
|
|
282
|
-
|
|
361
|
+
resume: false,
|
|
283
362
|
});
|
|
284
363
|
return view(await ctx.session.get({ sessionID: input.workerID }));
|
|
285
364
|
},
|
package/tui.ts
CHANGED
|
@@ -22,7 +22,7 @@ export default Plugin.define({
|
|
|
22
22
|
const projectID = ctx.data.session.get(tab.sessionID)?.projectID;
|
|
23
23
|
return {
|
|
24
24
|
sessionID: tab.sessionID,
|
|
25
|
-
priority: tab.busy || tab.
|
|
25
|
+
priority: tab.busy || tab.attention,
|
|
26
26
|
projectID:
|
|
27
27
|
typeof projectID === "string" && projectID.length > 0
|
|
28
28
|
? projectID
|
|
@@ -132,32 +132,26 @@ export default Plugin.define({
|
|
|
132
132
|
if (!role || !session?.title) continue;
|
|
133
133
|
const title = `[${role}] ${session.title.replace(/^\[(?:Main|Worker)\] /, "")}`;
|
|
134
134
|
if (title === session.title) continue;
|
|
135
|
-
await ctx.client.session.
|
|
135
|
+
await ctx.client.session.update({ sessionID: tab.sessionID, title });
|
|
136
136
|
}
|
|
137
137
|
groupTabs();
|
|
138
138
|
}
|
|
139
|
+
lastError = undefined;
|
|
140
|
+
} catch (error) {
|
|
141
|
+
const message = `Managed worker tabs: ${String(error)}`;
|
|
142
|
+
if (!stopped && message !== lastError)
|
|
143
|
+
ctx.ui.toast.show({ message, variant: "error" });
|
|
144
|
+
lastError = message;
|
|
139
145
|
} finally {
|
|
140
146
|
running = false;
|
|
141
147
|
if (reopenPending) {
|
|
142
148
|
reopenPending = false;
|
|
143
|
-
void reconcile(true)
|
|
144
|
-
ctx.ui.toast.show({ message: String(error), variant: "error" }),
|
|
145
|
-
);
|
|
149
|
+
void reconcile(true);
|
|
146
150
|
}
|
|
147
151
|
}
|
|
148
152
|
}
|
|
149
153
|
const refresh = () => {
|
|
150
|
-
void reconcile()
|
|
151
|
-
() => {
|
|
152
|
-
lastError = undefined;
|
|
153
|
-
},
|
|
154
|
-
(error: unknown) => {
|
|
155
|
-
const message = `Managed worker tabs: ${String(error)}`;
|
|
156
|
-
if (!stopped && message !== lastError)
|
|
157
|
-
ctx.ui.toast.show({ message, variant: "error" });
|
|
158
|
-
lastError = message;
|
|
159
|
-
},
|
|
160
|
-
);
|
|
154
|
+
void reconcile();
|
|
161
155
|
};
|
|
162
156
|
const stopEvents = ctx.data.listen(({ details }) => {
|
|
163
157
|
if (details.type.startsWith("session.")) refresh();
|
|
@@ -178,16 +172,7 @@ export default Plugin.define({
|
|
|
178
172
|
title: "Reopen managed worker tabs",
|
|
179
173
|
palette: true,
|
|
180
174
|
slash: { name: "threads" },
|
|
181
|
-
run:
|
|
182
|
-
try {
|
|
183
|
-
await reconcile(true);
|
|
184
|
-
} catch (error) {
|
|
185
|
-
ctx.ui.toast.show({
|
|
186
|
-
message: String(error),
|
|
187
|
-
variant: "error",
|
|
188
|
-
});
|
|
189
|
-
}
|
|
190
|
-
},
|
|
175
|
+
run: () => reconcile(true),
|
|
191
176
|
},
|
|
192
177
|
],
|
|
193
178
|
}));
|