@op1/threads 0.1.4 → 0.1.5

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 CHANGED
@@ -82,6 +82,8 @@ Plugin option `maxWorkers` defaults to 4 and accepts integers from 1 through 32.
82
82
 
83
83
  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
84
 
85
+ 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.
86
+
85
87
  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
88
 
87
89
  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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@op1/threads",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
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",
@@ -32,6 +32,9 @@
32
32
  "@opencode/schema": "2.0.3",
33
33
  "zod": "4.1.8"
34
34
  },
35
+ "peerDependencies": {
36
+ "solid-js": ">=1.9.0"
37
+ },
35
38
  "devDependencies": {
36
39
  "@types/bun": "1.4.0",
37
40
  "solid-js": "1.9.9",
@@ -46,4 +46,6 @@ A worker report is a claim to review. Run the protocol's independent verificatio
46
46
 
47
47
  ## Visibility
48
48
 
49
+ 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.
50
+
49
51
  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`.
package/tui.ts CHANGED
@@ -1,16 +1,22 @@
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;
@@ -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 (ctx.ui.tabs.list()[index]?.sessionID === sessionID) continue;
43
- if (!ctx.ui.tabs.move(sessionID, index)) break;
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().map((tab) => tab.sessionID),
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 && !ctx.ui.tabs.close(worker.workerID)) continue;
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()) groupTabs();
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: [