@op1/threads 0.1.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/LICENSE +21 -0
- package/README.md +95 -0
- package/index.ts +106 -0
- package/package.json +39 -0
- package/skills/managed-sessions/SKILL.md +40 -0
- package/src/rpc.ts +34 -0
- package/src/threads.ts +278 -0
- package/tui.ts +110 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 opzero1
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# @op1/threads
|
|
2
|
+
|
|
3
|
+
Managed top-level worker sessions for OpenCode 2.0.3. The server entrypoint is `index.ts`; the terminal entrypoint is `tui.ts`.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
Install the plugin globally:
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
opencode plugin add @op1/threads
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Or add it to `plugins` in `~/.config/opencode/opencode.jsonc`:
|
|
14
|
+
|
|
15
|
+
```jsonc
|
|
16
|
+
{
|
|
17
|
+
"plugins": ["@op1/threads"]
|
|
18
|
+
}
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Keep the other entries in your plugin list. Open a fresh TUI to load the terminal entrypoint. Session tabs must be enabled.
|
|
22
|
+
|
|
23
|
+
The `skills/managed-sessions` directory contains the VERA delegation guide. Copy or link it into `~/.config/opencode/skills/managed-sessions` to make it available to agents.
|
|
24
|
+
|
|
25
|
+
For local development, clone [opzero1/threads](https://github.com/opzero1/threads), run `bun install`, and use the clone's absolute path as the plugin entry instead. The internal plugin ID remains `op-threads`, so switching between local and published installs preserves worker records.
|
|
26
|
+
|
|
27
|
+
## Delegate work
|
|
28
|
+
|
|
29
|
+
```text
|
|
30
|
+
threads_spawn({
|
|
31
|
+
key: "review-auth",
|
|
32
|
+
title: "Review authentication",
|
|
33
|
+
directory: "/absolute/path/to/assigned-worktree",
|
|
34
|
+
task: "Review the authentication changes. Report findings and verification evidence."
|
|
35
|
+
})
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
The worker opens in a native tab without taking focus. Select that tab to read its conversation. Use `threads_list` to inspect reports and `/threads` to reopen closed worker tabs. Native `subagent` remains available for bounded tasks and role-specific reviews.
|
|
39
|
+
|
|
40
|
+
## Tools
|
|
41
|
+
|
|
42
|
+
The native tool names use namespace `threads` and individual names `spawn`, `list`, `send`, `interrupt`, and `report`.
|
|
43
|
+
|
|
44
|
+
| Tool | Input | Result |
|
|
45
|
+
| --- | --- | --- |
|
|
46
|
+
| `threads_spawn` | `{ key, title, directory, task }` | Worker view |
|
|
47
|
+
| `threads_list` | `{}` | `{ workers: WorkerView[] }` |
|
|
48
|
+
| `threads_send` | `{ workerID, key, text }` | `{ workerID, messageID }` |
|
|
49
|
+
| `threads_interrupt` | `{ workerID }` | Worker view |
|
|
50
|
+
| `threads_report` | `{ verdict, summary, evidence }` | `{ workerID, report }` |
|
|
51
|
+
|
|
52
|
+
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`.
|
|
53
|
+
|
|
54
|
+
`directory` must exist and be absolute. Spawn uses native `subagent` permission gating. The worker uses the coordinator's agent and model, with resolved agent permissions followed by session permissions. Its actual tool actions still pass through native permission checks. There is no separate directory-approval flow or agent/model override.
|
|
55
|
+
|
|
56
|
+
Tool identity comes from the calling session. Only the owning coordinator can send or interrupt. Only the exact original top-level worker can report. Native subagents and managed workers cannot spawn managed workers. Native `subagent` remains available.
|
|
57
|
+
|
|
58
|
+
## Identity and retries
|
|
59
|
+
|
|
60
|
+
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`.
|
|
61
|
+
|
|
62
|
+
The first create includes both message IDs. Subsequent creates adopt the original metadata returned by OpenCode. Identical spawn retries reuse the initial message ID. Changing the title, directory, or task under that key is an error. Startup never replays initial prompts.
|
|
63
|
+
|
|
64
|
+
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.
|
|
65
|
+
|
|
66
|
+
## Worker views and limits
|
|
67
|
+
|
|
68
|
+
`WorkerView` contains `workerID`, `coordinatorID`, `key`, `title`, `directory`, `outcome`, and `report`. `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.
|
|
69
|
+
|
|
70
|
+
`report` is the explicit worker claim, or `null`. Native `succeeded` means the agent loop completed, not that the assigned task passed.
|
|
71
|
+
|
|
72
|
+
Plugin option `maxWorkers` defaults to 4 and accepts integers from 1 through 32. Admission is serialized by coordinator within the loaded server process. A worker without a report continues to occupy a slot unless its native outcome is `failed` or `interrupted`. A successful run without a report does not silently free its slot.
|
|
73
|
+
|
|
74
|
+
## Terminal and RPC
|
|
75
|
+
|
|
76
|
+
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.
|
|
77
|
+
|
|
78
|
+
The read-only RPC definition is `ThreadsRpc` in `src/rpc.ts`, with ID `threads` and method `snapshot`:
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
input: { coordinatorIDs: string[] }
|
|
82
|
+
result: { workers: WorkerView[] }
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
The input accepts at most 100 coordinator IDs. Raw HTTP RPC requests wrap the input as `{ "input": { "coordinatorIDs": ["ses_..."] } }`. The method declares `errors: {}` and the RPC declares `events: {}`. The TUI subscribes to native session events and reconciles at most one snapshot at a time, with a three-second missed-event refresh.
|
|
86
|
+
|
|
87
|
+
## Verification and limits
|
|
88
|
+
|
|
89
|
+
Run `bun run typecheck`, `bun test`, and `bun run verify:live`. The live check requires OpenCode 2.0.3, 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.
|
|
90
|
+
|
|
91
|
+
Pass an extracted package directory to test the release artifact: `bun run verify:live /absolute/path/to/package`.
|
|
92
|
+
|
|
93
|
+
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.
|
|
94
|
+
|
|
95
|
+
Snapshots remove stale index entries for deleted sessions. Admission locking assumes one OpenCode server process. Workers that omit `threads_report` have no task verdict; their native execution outcomes remain visible separately.
|
package/index.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { Plugin } from "@opencode/plugin";
|
|
2
|
+
import type { SessionContext } from "@opencode/plugin/promise/session";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { Report, ThreadsRpc, WorkerView } from "./src/rpc";
|
|
5
|
+
import { Interrupt, Send, Spawn, threads } from "./src/threads";
|
|
6
|
+
|
|
7
|
+
export default Plugin.define({
|
|
8
|
+
id: "op-threads",
|
|
9
|
+
async setup(ctx) {
|
|
10
|
+
const limit = z
|
|
11
|
+
.number()
|
|
12
|
+
.int()
|
|
13
|
+
.min(1)
|
|
14
|
+
.max(32)
|
|
15
|
+
.default(4)
|
|
16
|
+
.parse(ctx.options.maxWorkers);
|
|
17
|
+
const workers = threads(ctx, limit);
|
|
18
|
+
const models = new Map<
|
|
19
|
+
SessionContext["sessionID"],
|
|
20
|
+
SessionContext["model"]
|
|
21
|
+
>();
|
|
22
|
+
await ctx.session.hook("context", (event) => {
|
|
23
|
+
models.set(event.sessionID, event.model);
|
|
24
|
+
});
|
|
25
|
+
await ctx.rpc.register(ThreadsRpc, {
|
|
26
|
+
snapshot: async ({ coordinatorIDs }) => ({
|
|
27
|
+
workers: (await Promise.all(coordinatorIDs.map(workers.list))).flat(),
|
|
28
|
+
}),
|
|
29
|
+
});
|
|
30
|
+
await ctx.tool.transform((editor) => {
|
|
31
|
+
editor.namespace({
|
|
32
|
+
name: "threads",
|
|
33
|
+
description: "Independent managed worker sessions",
|
|
34
|
+
});
|
|
35
|
+
editor.add({
|
|
36
|
+
name: "spawn",
|
|
37
|
+
description:
|
|
38
|
+
"Delegate a task to a top-level worker in an existing absolute directory. Reuse key only for identical requests. No managed grandchildren.",
|
|
39
|
+
input: Spawn,
|
|
40
|
+
output: WorkerView,
|
|
41
|
+
options: {
|
|
42
|
+
namespace: "threads",
|
|
43
|
+
permission: "subagent",
|
|
44
|
+
codemode: false,
|
|
45
|
+
},
|
|
46
|
+
execute: async (input, tool) => {
|
|
47
|
+
const model = models.get(tool.sessionID);
|
|
48
|
+
if (!model)
|
|
49
|
+
throw new Error("Spawn requires a resolved session context model");
|
|
50
|
+
const output = await workers.spawn(tool.sessionID, input, {
|
|
51
|
+
agent: tool.agent,
|
|
52
|
+
model,
|
|
53
|
+
});
|
|
54
|
+
return { content: JSON.stringify(output), output };
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
editor.add({
|
|
58
|
+
name: "list",
|
|
59
|
+
description:
|
|
60
|
+
"List your managed workers, last native execution outcomes, and explicit reports. Idle is not task success.",
|
|
61
|
+
input: z.object({}).strict(),
|
|
62
|
+
output: z.object({ workers: z.array(WorkerView) }),
|
|
63
|
+
options: { namespace: "threads", codemode: false },
|
|
64
|
+
execute: async (_input, tool) => {
|
|
65
|
+
const output = { workers: await workers.list(tool.sessionID) };
|
|
66
|
+
return { content: JSON.stringify(output), output };
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
editor.add({
|
|
70
|
+
name: "send",
|
|
71
|
+
description:
|
|
72
|
+
"Send a follow-up to your worker. Reuse key for an identical retry.",
|
|
73
|
+
input: Send,
|
|
74
|
+
output: z.object({ workerID: z.string(), messageID: z.string() }),
|
|
75
|
+
options: { namespace: "threads", codemode: false },
|
|
76
|
+
execute: async (input, tool) => {
|
|
77
|
+
const output = await workers.send(tool.sessionID, input);
|
|
78
|
+
return { content: JSON.stringify(output), output };
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
editor.add({
|
|
82
|
+
name: "interrupt",
|
|
83
|
+
description: "Interrupt your worker without claiming task success.",
|
|
84
|
+
input: Interrupt,
|
|
85
|
+
output: WorkerView,
|
|
86
|
+
options: { namespace: "threads", codemode: false },
|
|
87
|
+
execute: async (input, tool) => {
|
|
88
|
+
const output = await workers.interrupt(tool.sessionID, input);
|
|
89
|
+
return { content: JSON.stringify(output), output };
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
editor.add({
|
|
93
|
+
name: "report",
|
|
94
|
+
description:
|
|
95
|
+
"Report your managed task verdict and evidence to your coordinator. Identical retries return the original report; conflicting reports are rejected. Use a new spawn key for a new task.",
|
|
96
|
+
input: Report,
|
|
97
|
+
output: z.object({ workerID: z.string(), report: Report }),
|
|
98
|
+
options: { namespace: "threads", codemode: false },
|
|
99
|
+
execute: async (input, tool) => {
|
|
100
|
+
const output = await workers.report(tool.sessionID, input);
|
|
101
|
+
return { content: JSON.stringify(output), output };
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
},
|
|
106
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@op1/threads",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Visible top-level worker sessions for OpenCode V2, with native tabs and durable reports.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./index.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./index.ts",
|
|
9
|
+
"./tui": "./tui.ts"
|
|
10
|
+
},
|
|
11
|
+
"files": ["index.ts", "tui.ts", "src/", "skills/"],
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "git+https://github.com/opzero1/threads.git"
|
|
15
|
+
},
|
|
16
|
+
"homepage": "https://github.com/opzero1/threads#readme",
|
|
17
|
+
"bugs": "https://github.com/opzero1/threads/issues",
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"keywords": ["opencode", "opencode-plugin", "agents", "threads"],
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public",
|
|
22
|
+
"registry": "https://registry.npmjs.org/"
|
|
23
|
+
},
|
|
24
|
+
"scripts": {
|
|
25
|
+
"typecheck": "tsc --noEmit",
|
|
26
|
+
"test": "bun test",
|
|
27
|
+
"verify:live": "uv run --with pyte python scripts/verify-live.py"
|
|
28
|
+
},
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"@opencode/plugin": "2.0.3",
|
|
31
|
+
"@opencode/schema": "2.0.3",
|
|
32
|
+
"zod": "4.1.8"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@types/bun": "1.4.0",
|
|
36
|
+
"solid-js": "1.9.9",
|
|
37
|
+
"typescript": "5.9.3"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: Managed sessions
|
|
3
|
+
description: Spawn and monitor visible top-level OpenCode workers with op-threads. Use when VERA needs independent workstreams, separate conversations, or durable workers alongside native subagents.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Managed sessions
|
|
7
|
+
|
|
8
|
+
Choose the delegation mode before launching work.
|
|
9
|
+
|
|
10
|
+
- Use native `subagent` for a bounded task or review.
|
|
11
|
+
- Use `threads_spawn` for an independent workstream with its own visible session and assigned directory.
|
|
12
|
+
|
|
13
|
+
Count both modes against the active protocol's delegation budget. Managed workers can use native subagents, but cannot create further managed workers. Keep every writer in its own assigned worktree. These tools do not create worktrees.
|
|
14
|
+
|
|
15
|
+
## Start work
|
|
16
|
+
|
|
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
|
+
2. Assign an existing absolute directory and a stable task key.
|
|
19
|
+
3. Call `threads_spawn` with `key`, `title`, `directory`, and `task`. Include the goal, scope, relevant context, constraints, acceptance criteria, verification commands, and expected report in `task`.
|
|
20
|
+
4. Save the returned worker session ID with the work unit.
|
|
21
|
+
|
|
22
|
+
An identical spawn key retries the original admission. Different work requires a new key. A worker inherits the coordinator's agent, model, and permission constraints. It has a separate conversation, so include all context it needs in the task brief.
|
|
23
|
+
|
|
24
|
+
## Coordinate
|
|
25
|
+
|
|
26
|
+
- Use `threads_list` for a progress snapshot when making a scheduling or delivery decision.
|
|
27
|
+
- Use `threads_send` with a stable message key for a correction or follow-up within the worker's assigned task.
|
|
28
|
+
- Use `threads_interrupt` to stop that worker's execution.
|
|
29
|
+
- Do not repeatedly query status while waiting. Workers report to the coordinator through OpenCode's durable inbox.
|
|
30
|
+
- After a service restart, inspect existing worker IDs before deciding whether to resume them. Do not launch replacement tasks solely because a session is idle.
|
|
31
|
+
|
|
32
|
+
## Finish
|
|
33
|
+
|
|
34
|
+
The worker calls `threads_report` with a verdict, concise summary, and concrete evidence, then stops. Valid verdicts are `PASS`, `PASS WITH NOTES`, `FAIL`, and `INCONCLUSIVE`. Report partial work and blockers honestly.
|
|
35
|
+
|
|
36
|
+
A worker report is a claim to review. Run the protocol's independent verification before marking the work unit complete. An idle session or a stopped loading indicator does not establish task success.
|
|
37
|
+
|
|
38
|
+
## Visibility
|
|
39
|
+
|
|
40
|
+
The TUI plugin opens managed workers as ordinary tabs without changing focus. Each tab uses OpenCode's native activity indicators. Use `/threads` to reopen managed worker tabs you closed.
|
package/src/rpc.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { Rpc } from "@opencode/plugin/rpc";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
|
|
4
|
+
export const Report = z
|
|
5
|
+
.object({
|
|
6
|
+
verdict: z.enum(["PASS", "PASS WITH NOTES", "FAIL", "INCONCLUSIVE"]),
|
|
7
|
+
summary: z.string().min(1),
|
|
8
|
+
evidence: z.array(z.string()),
|
|
9
|
+
})
|
|
10
|
+
.strict();
|
|
11
|
+
|
|
12
|
+
export const WorkerView = z.object({
|
|
13
|
+
workerID: z.string(),
|
|
14
|
+
coordinatorID: z.string(),
|
|
15
|
+
key: z.string(),
|
|
16
|
+
title: z.string(),
|
|
17
|
+
directory: z.string(),
|
|
18
|
+
outcome: z.enum(["succeeded", "failed", "interrupted"]).nullable(),
|
|
19
|
+
report: Report.nullable(),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
export const ThreadsRpc = Rpc.define({
|
|
23
|
+
id: "threads",
|
|
24
|
+
methods: {
|
|
25
|
+
snapshot: {
|
|
26
|
+
input: z
|
|
27
|
+
.object({ coordinatorIDs: z.array(z.string()).max(100) })
|
|
28
|
+
.strict(),
|
|
29
|
+
output: z.object({ workers: z.array(WorkerView) }),
|
|
30
|
+
errors: {},
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
events: {},
|
|
34
|
+
});
|
package/src/threads.ts
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { stat } from "node:fs/promises";
|
|
3
|
+
import { isAbsolute } from "node:path";
|
|
4
|
+
import type { Plugin } from "@opencode/plugin";
|
|
5
|
+
import type { SessionContext } from "@opencode/plugin/promise/session";
|
|
6
|
+
import type { ToolContext } from "@opencode/plugin/promise/tool";
|
|
7
|
+
import { Session } from "@opencode/schema/session";
|
|
8
|
+
import { SessionMessage } from "@opencode/schema/session-message";
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
import { Report, WorkerView } from "./rpc";
|
|
11
|
+
|
|
12
|
+
const sessionID = z.string().transform((value) => Session.ID.make(value));
|
|
13
|
+
const messageID = z
|
|
14
|
+
.string()
|
|
15
|
+
.transform((value) => SessionMessage.ID.make(value));
|
|
16
|
+
const MissingSession = z.object({
|
|
17
|
+
_tag: z.literal("Session.NotFoundError"),
|
|
18
|
+
sessionID,
|
|
19
|
+
});
|
|
20
|
+
export const Link = z
|
|
21
|
+
.object({
|
|
22
|
+
workerID: sessionID,
|
|
23
|
+
coordinatorID: sessionID,
|
|
24
|
+
key: z.string().min(1),
|
|
25
|
+
fingerprint: z.string().min(1),
|
|
26
|
+
initialMessageID: messageID,
|
|
27
|
+
reportMessageID: messageID,
|
|
28
|
+
})
|
|
29
|
+
.strict();
|
|
30
|
+
export const Spawn = z
|
|
31
|
+
.object({
|
|
32
|
+
key: z.string().min(1).max(200),
|
|
33
|
+
title: z.string().min(1),
|
|
34
|
+
directory: z.string().min(1),
|
|
35
|
+
task: z.string().min(1),
|
|
36
|
+
})
|
|
37
|
+
.strict();
|
|
38
|
+
export const Send = z
|
|
39
|
+
.object({
|
|
40
|
+
workerID: sessionID,
|
|
41
|
+
key: z.string().min(1).max(200),
|
|
42
|
+
text: z.string().min(1),
|
|
43
|
+
})
|
|
44
|
+
.strict();
|
|
45
|
+
export const Interrupt = z.object({ workerID: sessionID }).strict();
|
|
46
|
+
|
|
47
|
+
const digest = (parts: string[]) =>
|
|
48
|
+
createHash("sha256").update(JSON.stringify(parts)).digest("hex");
|
|
49
|
+
export const workerIdentity = (coordinatorID: string, key: string) =>
|
|
50
|
+
Session.ID.make(`ses_${digest([coordinatorID, key]).slice(0, 32)}`);
|
|
51
|
+
export const fingerprint = (input: z.infer<typeof Spawn>) =>
|
|
52
|
+
digest([input.title, input.directory, input.task]);
|
|
53
|
+
|
|
54
|
+
const locks = new Map<string, Promise<void>>();
|
|
55
|
+
export async function serialized<T>(
|
|
56
|
+
key: string,
|
|
57
|
+
run: () => Promise<T>,
|
|
58
|
+
): Promise<T> {
|
|
59
|
+
const previous = locks.get(key) ?? Promise.resolve();
|
|
60
|
+
let release = () => {};
|
|
61
|
+
const current = new Promise<void>((resolve) => {
|
|
62
|
+
release = resolve;
|
|
63
|
+
});
|
|
64
|
+
locks.set(key, current);
|
|
65
|
+
await previous;
|
|
66
|
+
try {
|
|
67
|
+
return await run();
|
|
68
|
+
} finally {
|
|
69
|
+
release();
|
|
70
|
+
if (locks.get(key) === current) locks.delete(key);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
type NativeSession = Awaited<ReturnType<Plugin.Context["session"]["get"]>>;
|
|
75
|
+
export function workerLink(
|
|
76
|
+
session: Pick<NativeSession, "id" | "parentID" | "metadata">,
|
|
77
|
+
) {
|
|
78
|
+
const link = Link.parse(session.metadata?.opThreads);
|
|
79
|
+
if (
|
|
80
|
+
session.parentID !== undefined ||
|
|
81
|
+
link.workerID !== session.id ||
|
|
82
|
+
workerIdentity(link.coordinatorID, link.key) !== session.id
|
|
83
|
+
) {
|
|
84
|
+
throw new Error("Not the original managed worker");
|
|
85
|
+
}
|
|
86
|
+
return link;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function threads(
|
|
90
|
+
ctx: Pick<Plugin.Context, "session" | "agent" | "storage">,
|
|
91
|
+
limit = 4,
|
|
92
|
+
) {
|
|
93
|
+
const indexKey = (link: z.infer<typeof Link>) =>
|
|
94
|
+
`workers/${link.coordinatorID}/${link.workerID}`;
|
|
95
|
+
const reportKey = (link: z.infer<typeof Link>) =>
|
|
96
|
+
`reports/${link.workerID}/${link.reportMessageID}`;
|
|
97
|
+
|
|
98
|
+
async function view(
|
|
99
|
+
session: NativeSession,
|
|
100
|
+
): Promise<z.infer<typeof WorkerView>> {
|
|
101
|
+
const link = workerLink(session);
|
|
102
|
+
const stored = await ctx.storage.get(reportKey(link));
|
|
103
|
+
return {
|
|
104
|
+
workerID: link.workerID,
|
|
105
|
+
coordinatorID: link.coordinatorID,
|
|
106
|
+
key: link.key,
|
|
107
|
+
title: session.title ?? link.key,
|
|
108
|
+
directory: session.location.directory,
|
|
109
|
+
outcome: session.outcome ?? null,
|
|
110
|
+
report: stored === undefined ? null : Report.parse(stored),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function list(coordinatorID: string) {
|
|
115
|
+
const workers: z.infer<typeof WorkerView>[] = [];
|
|
116
|
+
let after: string | undefined;
|
|
117
|
+
do {
|
|
118
|
+
const page = await ctx.storage.scan({
|
|
119
|
+
prefix: `workers/${Session.ID.make(coordinatorID)}/`,
|
|
120
|
+
after,
|
|
121
|
+
limit: 100,
|
|
122
|
+
});
|
|
123
|
+
for (const entry of page.entries) {
|
|
124
|
+
const link = Link.parse(entry.value);
|
|
125
|
+
if (link.coordinatorID !== coordinatorID)
|
|
126
|
+
throw new Error("Worker index ownership mismatch");
|
|
127
|
+
let session: NativeSession;
|
|
128
|
+
try {
|
|
129
|
+
session = await ctx.session.get({ sessionID: link.workerID });
|
|
130
|
+
} catch (error) {
|
|
131
|
+
const missing = MissingSession.safeParse(error);
|
|
132
|
+
if (!missing.success || missing.data.sessionID !== link.workerID)
|
|
133
|
+
throw error;
|
|
134
|
+
await ctx.storage.remove(reportKey(link));
|
|
135
|
+
await ctx.storage.remove(entry.key);
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
workers.push(await view(session));
|
|
139
|
+
}
|
|
140
|
+
after = page.next;
|
|
141
|
+
} while (after);
|
|
142
|
+
return workers;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function owned(actor: string, workerID: string) {
|
|
146
|
+
const session = await ctx.session.get({ sessionID: workerID });
|
|
147
|
+
const link = workerLink(session);
|
|
148
|
+
if (link.coordinatorID !== actor)
|
|
149
|
+
throw new Error("Only the owning coordinator may control this worker");
|
|
150
|
+
return { session, link };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
list,
|
|
155
|
+
async spawn(
|
|
156
|
+
actor: string,
|
|
157
|
+
input: z.infer<typeof Spawn>,
|
|
158
|
+
runtime: Pick<ToolContext, "agent"> & Pick<SessionContext, "model">,
|
|
159
|
+
) {
|
|
160
|
+
return serialized(actor, async () => {
|
|
161
|
+
const coordinator = await ctx.session.get({ sessionID: actor });
|
|
162
|
+
if (
|
|
163
|
+
coordinator.parentID !== undefined ||
|
|
164
|
+
coordinator.metadata?.opThreads !== undefined
|
|
165
|
+
) {
|
|
166
|
+
throw new Error(
|
|
167
|
+
"Native subagents and managed workers cannot spawn managed workers",
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
if (
|
|
171
|
+
!isAbsolute(input.directory) ||
|
|
172
|
+
!(await stat(input.directory)).isDirectory()
|
|
173
|
+
) {
|
|
174
|
+
throw new Error("directory must be an existing absolute directory");
|
|
175
|
+
}
|
|
176
|
+
const workerID = workerIdentity(actor, input.key);
|
|
177
|
+
const existing = await list(actor);
|
|
178
|
+
if (
|
|
179
|
+
!existing.some((worker) => worker.workerID === workerID) &&
|
|
180
|
+
existing.filter(
|
|
181
|
+
(worker) =>
|
|
182
|
+
!worker.report &&
|
|
183
|
+
worker.outcome !== "failed" &&
|
|
184
|
+
worker.outcome !== "interrupted",
|
|
185
|
+
).length >= limit
|
|
186
|
+
) {
|
|
187
|
+
throw new Error(`Coordinator worker limit reached (${limit})`);
|
|
188
|
+
}
|
|
189
|
+
const agent = await ctx.agent.get({
|
|
190
|
+
agentID: runtime.agent,
|
|
191
|
+
location: coordinator.location,
|
|
192
|
+
});
|
|
193
|
+
const proposed = Link.parse({
|
|
194
|
+
workerID,
|
|
195
|
+
coordinatorID: actor,
|
|
196
|
+
key: input.key,
|
|
197
|
+
fingerprint: fingerprint(input),
|
|
198
|
+
initialMessageID: SessionMessage.ID.create(),
|
|
199
|
+
reportMessageID: SessionMessage.ID.create(),
|
|
200
|
+
});
|
|
201
|
+
const session = await ctx.session.create({
|
|
202
|
+
id: workerID,
|
|
203
|
+
title: input.title,
|
|
204
|
+
location: { directory: input.directory },
|
|
205
|
+
agent: runtime.agent,
|
|
206
|
+
model: runtime.model,
|
|
207
|
+
permissions: [
|
|
208
|
+
...agent.data.permissions,
|
|
209
|
+
...(coordinator.permissions ?? []),
|
|
210
|
+
],
|
|
211
|
+
metadata: { opThreads: proposed },
|
|
212
|
+
});
|
|
213
|
+
const link = workerLink(session);
|
|
214
|
+
if (
|
|
215
|
+
link.coordinatorID !== actor ||
|
|
216
|
+
link.fingerprint !== proposed.fingerprint
|
|
217
|
+
) {
|
|
218
|
+
throw new Error(
|
|
219
|
+
"This spawn key already belongs to a different request",
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
await ctx.storage.set(indexKey(link), link);
|
|
223
|
+
await ctx.session.prompt({
|
|
224
|
+
sessionID: link.workerID,
|
|
225
|
+
id: link.initialMessageID,
|
|
226
|
+
delivery: "queue",
|
|
227
|
+
text: `${input.task}\n\nYou are a managed worker assigned to ${input.directory}. Work only within the assigned scope. Do not call threads_spawn. When finished call threads_report with verdict, summary, and evidence. Runtime completion alone does not establish task success.`,
|
|
228
|
+
});
|
|
229
|
+
return view(await ctx.session.get({ sessionID: workerID }));
|
|
230
|
+
});
|
|
231
|
+
},
|
|
232
|
+
async send(actor: string, input: z.infer<typeof Send>) {
|
|
233
|
+
await owned(actor, input.workerID);
|
|
234
|
+
const id = SessionMessage.ID.make(
|
|
235
|
+
`msg_${digest([input.workerID, "send", input.key]).slice(0, 32)}`,
|
|
236
|
+
);
|
|
237
|
+
const admitted = await ctx.session.synthetic({
|
|
238
|
+
sessionID: input.workerID,
|
|
239
|
+
id,
|
|
240
|
+
text: input.text,
|
|
241
|
+
delivery: "queue",
|
|
242
|
+
resume: true,
|
|
243
|
+
});
|
|
244
|
+
if (admitted.payload.text !== input.text)
|
|
245
|
+
throw new Error("This send key already belongs to different text");
|
|
246
|
+
return { workerID: input.workerID, messageID: admitted.id };
|
|
247
|
+
},
|
|
248
|
+
async interrupt(actor: string, input: z.infer<typeof Interrupt>) {
|
|
249
|
+
await owned(actor, input.workerID);
|
|
250
|
+
await ctx.session.interrupt({
|
|
251
|
+
sessionID: input.workerID,
|
|
252
|
+
continue: false,
|
|
253
|
+
});
|
|
254
|
+
return view(await ctx.session.get({ sessionID: input.workerID }));
|
|
255
|
+
},
|
|
256
|
+
async report(actor: string, input: z.infer<typeof Report>) {
|
|
257
|
+
const session = await ctx.session.get({ sessionID: actor });
|
|
258
|
+
const link = workerLink(session);
|
|
259
|
+
const admitted = await ctx.session.synthetic({
|
|
260
|
+
sessionID: link.coordinatorID,
|
|
261
|
+
id: link.reportMessageID,
|
|
262
|
+
text: `Managed worker ${link.workerID} (${link.key}) report:\n${JSON.stringify(input)}`,
|
|
263
|
+
metadata: { opThreadsReport: input, workerID: link.workerID },
|
|
264
|
+
description: `Worker report: ${link.key}`,
|
|
265
|
+
delivery: "queue",
|
|
266
|
+
resume: true,
|
|
267
|
+
});
|
|
268
|
+
const canonical = Report.parse(
|
|
269
|
+
admitted.payload.metadata?.opThreadsReport,
|
|
270
|
+
);
|
|
271
|
+
await ctx.storage.set(reportKey(link), canonical);
|
|
272
|
+
if (JSON.stringify(canonical) !== JSON.stringify(input)) {
|
|
273
|
+
throw new Error("This worker already has a different report. Start a new task with a new spawn key.");
|
|
274
|
+
}
|
|
275
|
+
return { workerID: link.workerID, report: canonical };
|
|
276
|
+
},
|
|
277
|
+
};
|
|
278
|
+
}
|
package/tui.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { Plugin } from "@opencode/plugin/tui";
|
|
2
|
+
import { ThreadsRpc } from "./src/rpc";
|
|
3
|
+
|
|
4
|
+
export default Plugin.define({
|
|
5
|
+
id: "op-threads",
|
|
6
|
+
setup(ctx) {
|
|
7
|
+
const rpc = ctx.client.rpc(ThreadsRpc);
|
|
8
|
+
const initial: { workerIDs: string[] } = { workerIDs: [] };
|
|
9
|
+
const [seen, updateSeen] = ctx.storage.memory("seen-workers", { initial });
|
|
10
|
+
let stopped = false;
|
|
11
|
+
let running = false;
|
|
12
|
+
let reopenPending = false;
|
|
13
|
+
let lastError: string | undefined;
|
|
14
|
+
async function reconcile(reopen = false) {
|
|
15
|
+
if (stopped || !ctx.ui.tabs.enabled()) return;
|
|
16
|
+
if (running) {
|
|
17
|
+
reopenPending ||= reopen;
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
running = true;
|
|
21
|
+
try {
|
|
22
|
+
const route = ctx.ui.router.current();
|
|
23
|
+
const coordinatorIDs = [
|
|
24
|
+
...new Set([
|
|
25
|
+
...ctx.ui.tabs.list().map((tab) => tab.sessionID),
|
|
26
|
+
...(route.type === "session" ? [route.sessionID] : []),
|
|
27
|
+
]),
|
|
28
|
+
].slice(0, 100);
|
|
29
|
+
if (!coordinatorIDs.length) return;
|
|
30
|
+
const { workers } = await rpc.snapshot(
|
|
31
|
+
{ coordinatorIDs },
|
|
32
|
+
{ location: ctx.location ?? ctx.data.location.default() },
|
|
33
|
+
);
|
|
34
|
+
for (const worker of workers) {
|
|
35
|
+
if (stopped) return;
|
|
36
|
+
if (!reopen && seen.workerIDs.includes(worker.workerID)) continue;
|
|
37
|
+
await ctx.data.session.sync(worker.workerID);
|
|
38
|
+
if (
|
|
39
|
+
!stopped &&
|
|
40
|
+
ctx.ui.tabs.open(worker.workerID) &&
|
|
41
|
+
!seen.workerIDs.includes(worker.workerID)
|
|
42
|
+
) {
|
|
43
|
+
updateSeen((draft) => {
|
|
44
|
+
draft.workerIDs.push(worker.workerID);
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
} finally {
|
|
49
|
+
running = false;
|
|
50
|
+
if (reopenPending) {
|
|
51
|
+
reopenPending = false;
|
|
52
|
+
void reconcile(true).catch((error) =>
|
|
53
|
+
ctx.ui.toast.show({ message: String(error), variant: "error" }),
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const refresh = () => {
|
|
59
|
+
void reconcile().then(
|
|
60
|
+
() => {
|
|
61
|
+
lastError = undefined;
|
|
62
|
+
},
|
|
63
|
+
(error: unknown) => {
|
|
64
|
+
const message = `Managed worker tabs: ${String(error)}`;
|
|
65
|
+
if (!stopped && message !== lastError)
|
|
66
|
+
ctx.ui.toast.show({ message, variant: "error" });
|
|
67
|
+
lastError = message;
|
|
68
|
+
},
|
|
69
|
+
);
|
|
70
|
+
};
|
|
71
|
+
const stopEvents = ctx.data.listen(({ details }) => {
|
|
72
|
+
if (details.type.startsWith("session.")) refresh();
|
|
73
|
+
});
|
|
74
|
+
const timer = setInterval(refresh, 3000);
|
|
75
|
+
const removeSlot = ctx.ui.slot({
|
|
76
|
+
append: "app",
|
|
77
|
+
render: () => {
|
|
78
|
+
ctx.keymap.layer(() => ({
|
|
79
|
+
mode: "global",
|
|
80
|
+
commands: [
|
|
81
|
+
{
|
|
82
|
+
id: "threads.reopen",
|
|
83
|
+
title: "Reopen managed worker tabs",
|
|
84
|
+
palette: true,
|
|
85
|
+
slash: { name: "threads" },
|
|
86
|
+
run: async () => {
|
|
87
|
+
try {
|
|
88
|
+
await reconcile(true);
|
|
89
|
+
} catch (error) {
|
|
90
|
+
ctx.ui.toast.show({
|
|
91
|
+
message: String(error),
|
|
92
|
+
variant: "error",
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
],
|
|
98
|
+
}));
|
|
99
|
+
return null;
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
refresh();
|
|
103
|
+
return () => {
|
|
104
|
+
stopped = true;
|
|
105
|
+
clearInterval(timer);
|
|
106
|
+
stopEvents();
|
|
107
|
+
removeSlot();
|
|
108
|
+
};
|
|
109
|
+
},
|
|
110
|
+
});
|