opencode-agora 0.1.0__py3-none-any.whl
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.
- agora_mcp.py +240 -0
- agorad/__init__.py +1 -0
- agorad/access/__init__.py +1 -0
- agorad/access/anchors.py +22 -0
- agorad/access/gitx.py +206 -0
- agorad/access/oc.py +124 -0
- agorad/access/pages.py +25 -0
- agorad/access/store.py +181 -0
- agorad/core/__init__.py +1 -0
- agorad/core/app.py +137 -0
- agorad/core/auth.py +134 -0
- agorad/core/gate.py +281 -0
- agorad/core/health.py +182 -0
- agorad/core/msg.py +92 -0
- agorad/core/peer.py +90 -0
- agorad/core/recover.py +83 -0
- agorad/core/report.py +189 -0
- agorad/core/state.py +183 -0
- agorad/core/task.py +308 -0
- agorad/installer.py +44 -0
- agorad/main.py +181 -0
- agorad/transport/__init__.py +1 -0
- agorad/transport/httpd.py +316 -0
- board_tui.py +649 -0
- opencode_agora-0.1.0.data/data/share/opencode-agora/agora +244 -0
- opencode_agora-0.1.0.data/data/share/opencode-agora/agora-plugin/cred.ts +112 -0
- opencode_agora-0.1.0.data/data/share/opencode-agora/agora-plugin/envPath.ts +33 -0
- opencode_agora-0.1.0.data/data/share/opencode-agora/agora-plugin/hook.ts +132 -0
- opencode_agora-0.1.0.data/data/share/opencode-agora/agora-plugin/index.ts +160 -0
- opencode_agora-0.1.0.data/data/share/opencode-agora/agora-plugin/inject.ts +49 -0
- opencode_agora-0.1.0.data/data/share/opencode-agora/agora-plugin/lift.ts +161 -0
- opencode_agora-0.1.0.data/data/share/opencode-agora/agora-plugin/scope.ts +36 -0
- opencode_agora-0.1.0.data/data/share/opencode-agora/install.sh +222 -0
- opencode_agora-0.1.0.data/data/share/opencode-agora/skills/agora-dispatch/SKILL.md +95 -0
- opencode_agora-0.1.0.data/data/share/opencode-agora/skills/agora-ops/SKILL.md +60 -0
- opencode_agora-0.1.0.dist-info/METADATA +195 -0
- opencode_agora-0.1.0.dist-info/RECORD +41 -0
- opencode_agora-0.1.0.dist-info/WHEEL +5 -0
- opencode_agora-0.1.0.dist-info/entry_points.txt +5 -0
- opencode_agora-0.1.0.dist-info/licenses/LICENSE +21 -0
- opencode_agora-0.1.0.dist-info/top_level.txt +3 -0
board_tui.py
ADDED
|
@@ -0,0 +1,649 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""board_tui——Agent View(F10/F5):curses 链树仪表盘,vi motion(less 形态)。
|
|
3
|
+
|
|
4
|
+
模型层纯函数与 curses 壳分离:build_rows(五桶计数行+链树任务行+升级事件
|
|
5
|
+
行)与 handle_key(键位表→Action)可测;壳只拉数据、画行、执行 Action。
|
|
6
|
+
数据面=agorad HTTP /board(五桶+链+逐任务明细);root 牌自动读
|
|
7
|
+
$AGORA_HOME/.agora/root.token,端口读 .agora/service.json;会话 tail 只读
|
|
8
|
+
$XDG_DATA_HOME/opencode/opencode.db(sqlite ro,失败降级为提示行)。
|
|
9
|
+
边界:无模式切换(less 同款);x 取消须 y 确认行;:命令行不做。
|
|
10
|
+
"""
|
|
11
|
+
import curses
|
|
12
|
+
import json
|
|
13
|
+
import locale
|
|
14
|
+
import os
|
|
15
|
+
import sqlite3
|
|
16
|
+
import sys
|
|
17
|
+
import time
|
|
18
|
+
import unicodedata
|
|
19
|
+
import urllib.request
|
|
20
|
+
from collections import namedtuple
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
# 升级事件五类(与 core/health 字面量对齐——独立命令不 import agorad)
|
|
24
|
+
_KINDS = frozenset(("quality", "gatefiles", "reinject-fail", "dispatcher-dead",
|
|
25
|
+
"stalled"))
|
|
26
|
+
|
|
27
|
+
# 五桶固序(计数行与数字键 1-5 同序);桶归属映射 _bucket 单点,禁两套口径
|
|
28
|
+
_BUCKETS = ("已合并", "在飞", "失败", "阻塞", "已取消")
|
|
29
|
+
# 桶显示名(gt-c05d6e):内部键=服务端 buckets 中文键,仅渲染层映射
|
|
30
|
+
_LABELS = {"已合并": "Merged", "在飞": "In-flight", "失败": "Failed",
|
|
31
|
+
"阻塞": "Blocked", "已取消": "Cancelled"}
|
|
32
|
+
|
|
33
|
+
Action = namedtuple("Action", "op task arg")
|
|
34
|
+
Action.__new__.__defaults__ = (None, None)
|
|
35
|
+
Row = namedtuple("Row", "kind tid depth state title worker last_seen note domain repo")
|
|
36
|
+
Row.__new__.__defaults__ = (None, 0, "", "", None, "", "", "", "")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# ---------------- 渲染模型(纯函数) ----------------
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _pad(s, width):
|
|
43
|
+
"""Truncate to display width then pad to width."""
|
|
44
|
+
out, w = [], 0
|
|
45
|
+
for c in s:
|
|
46
|
+
cw = 2 if unicodedata.east_asian_width(c) in "WF" else 1
|
|
47
|
+
if w + cw > width:
|
|
48
|
+
break
|
|
49
|
+
out.append(c)
|
|
50
|
+
w += cw
|
|
51
|
+
return "".join(out) + " " * (width - w)
|
|
52
|
+
|
|
53
|
+
def _bucket(state):
|
|
54
|
+
"""桶归属唯一口径:merged+closed→已合并;blocked→阻塞;cancelled→已取消;
|
|
55
|
+
其余(dispatched/running/done/verify_failed/reopened…)→在飞。"""
|
|
56
|
+
return ("已合并" if state in ("merged", "closed")
|
|
57
|
+
else "阻塞" if state == "blocked"
|
|
58
|
+
else "已取消" if state == "cancelled" else "在飞")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _domain(state):
|
|
62
|
+
"""状态着色域:verify_failed/reopened 是续修态归在飞(同五桶口径)。"""
|
|
63
|
+
return {"已合并": "merged", "在飞": "fly",
|
|
64
|
+
"阻塞": "blocked", "已取消": "gone"}[_bucket(state)]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _gkey(t):
|
|
68
|
+
"""项目分组键=仓 basename(Path 尾段);缺 project_dir 归 "?" 组。"""
|
|
69
|
+
return Path(t["project_dir"]).name if t.get("project_dir") else "?"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def rel_time(ts, now=None):
|
|
73
|
+
"""last_seen 相对时间:- / 5s / 1m / 2h / 2d。"""
|
|
74
|
+
if ts is None:
|
|
75
|
+
return "-"
|
|
76
|
+
d = max(0.0, (now if now is not None else time.time()) - ts)
|
|
77
|
+
if d < 60:
|
|
78
|
+
return f"{int(d)}s"
|
|
79
|
+
if d < 3600:
|
|
80
|
+
return f"{int(d // 60)}m"
|
|
81
|
+
if d < 86400:
|
|
82
|
+
return f"{int(d // 3600)}h"
|
|
83
|
+
return f"{int(d // 86400)}d"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def build_rows(snap, events, filt="", now=None, expanded=None, bucket=None,
|
|
87
|
+
group=None, gexpanded=None):
|
|
88
|
+
"""渲染模型:五桶计数行+链树任务行(缩进/着色域/相对时间)+升级事件行。
|
|
89
|
+
|
|
90
|
+
snap=HTTP /board 汇合(tasks 全集+chain 根链 BFS 序);events 全量审计
|
|
91
|
+
行,只取五类 escalate 紧随其任务行高亮;filt 子串滤(tid/title/state,
|
|
92
|
+
桶行恒在);bucket=激活桶视图(数字键 1-5):只显该桶任务行,与文本
|
|
93
|
+
过滤 AND 叠加、同样穿透折叠,计数行恒全局口径;桶行带键位数字前缀
|
|
94
|
+
("N Label count",gt-87f3c2),激活桶头并置 "*"。
|
|
95
|
+
group=项目分组视图(0 键):按 _gkey(仓 basename)分组渲染,组头行
|
|
96
|
+
(kind="group",题=标签+组员数,样式同桶行)按组内最新 created 降序,
|
|
97
|
+
组员保持 forest DFS 近序;gexpanded=组展开集,缺省空=全折叠只显组头,
|
|
98
|
+
文本/桶过滤与分组叠加且穿透组折叠(命中组员照显,组头恒在);组展开即
|
|
99
|
+
全显组员(组折叠语义取代子树折叠)。
|
|
100
|
+
链深按 worker→dispatcher 边上溯,环有界。行序 DFS 子树聚簇
|
|
101
|
+
(子代紧随父代,同层保持 chain 原序);expanded=展开集,任务行仅当全部
|
|
102
|
+
严格祖先都在其中才显示(空集=默认折叠只显根层),escalate 事件行随任务
|
|
103
|
+
行同显隐;过滤非空时穿透折叠,命中行照常显示。
|
|
104
|
+
"""
|
|
105
|
+
now = time.time() if now is None else now
|
|
106
|
+
expanded = expanded if expanded is not None else set()
|
|
107
|
+
gexp = gexpanded if gexpanded is not None else set()
|
|
108
|
+
tasks = snap.get("tasks", {})
|
|
109
|
+
chain = [tid for tid in snap.get("chain", []) if tid in tasks]
|
|
110
|
+
esc = {}
|
|
111
|
+
for e in events:
|
|
112
|
+
if (e.get("event") == "escalate" and e.get("kind") in _KINDS
|
|
113
|
+
and e.get("task") in chain):
|
|
114
|
+
esc.setdefault(e["task"], []).append(e["kind"])
|
|
115
|
+
parent = {tid: next((p for p in chain if tasks[p].get("worker_sid")
|
|
116
|
+
== tasks[tid].get("dispatcher_sid")), None)
|
|
117
|
+
for tid in chain}
|
|
118
|
+
depth = {}
|
|
119
|
+
for tid in chain:
|
|
120
|
+
seen, d, cur = set(), 0, tid
|
|
121
|
+
while cur not in seen:
|
|
122
|
+
seen.add(cur)
|
|
123
|
+
up = parent.get(cur)
|
|
124
|
+
if up is None:
|
|
125
|
+
break
|
|
126
|
+
d, cur = d + 1, up
|
|
127
|
+
depth[tid] = d
|
|
128
|
+
kids = {tid: [] for tid in chain}
|
|
129
|
+
roots = []
|
|
130
|
+
for tid in chain:
|
|
131
|
+
(roots if parent[tid] is None else kids[parent[tid]]).append(tid)
|
|
132
|
+
order = []
|
|
133
|
+
stack = roots[::-1]
|
|
134
|
+
while stack: # 显式栈 DFS:链深由远端 JSON 定,不触递归上限
|
|
135
|
+
tid = stack.pop()
|
|
136
|
+
order.append(tid)
|
|
137
|
+
stack.extend(reversed(kids[tid]))
|
|
138
|
+
order += [tid for tid in chain if tid not in set(order)] # 环上孤岛 DFS 不可达,保链序兜底
|
|
139
|
+
|
|
140
|
+
def _shown(tid):
|
|
141
|
+
cur, seen = parent.get(tid), set()
|
|
142
|
+
while cur is not None and cur not in seen:
|
|
143
|
+
seen.add(cur)
|
|
144
|
+
if cur not in expanded:
|
|
145
|
+
return False
|
|
146
|
+
cur = parent.get(cur)
|
|
147
|
+
return True
|
|
148
|
+
|
|
149
|
+
sb = snap.get("buckets")
|
|
150
|
+
if sb is not None: # 服务端全局五桶口径(gt-6def73)
|
|
151
|
+
buckets = {name: len(sb.get(name, [])) for name in _BUCKETS}
|
|
152
|
+
else: # 旧响应无 buckets:本地计数兜底(同 _bucket 口径)
|
|
153
|
+
buckets = dict.fromkeys(_BUCKETS, 0)
|
|
154
|
+
for t in tasks.values():
|
|
155
|
+
buckets[_bucket(t["state"])] += 1
|
|
156
|
+
rows = [Row("bucket", title=f"{'*' if name == bucket else ''}"
|
|
157
|
+
f"{_BUCKETS.index(name) + 1} {_LABELS.get(name, name)} {n}",
|
|
158
|
+
domain="bucket") # 数字前缀=键位 1-5 可发现性(gt-87f3c2)
|
|
159
|
+
for name, n in buckets.items()]
|
|
160
|
+
f = filt.lower()
|
|
161
|
+
|
|
162
|
+
def _emit(out, tid):
|
|
163
|
+
t = tasks[tid]
|
|
164
|
+
out.append(Row("task", tid, depth[tid], t["state"], t.get("title", ""),
|
|
165
|
+
t.get("worker_sid"), rel_time(t.get("last_seen"), now),
|
|
166
|
+
" ".join(esc.get(tid, [])), _domain(t["state"]),
|
|
167
|
+
Path(t["project_dir"]).name if t.get("project_dir")
|
|
168
|
+
else ""))
|
|
169
|
+
for kind in esc.get(tid, []):
|
|
170
|
+
out.append(Row("event", tid, depth[tid] + 1, "",
|
|
171
|
+
f"↑ escalate[{kind}]", note=kind, domain="warn"))
|
|
172
|
+
|
|
173
|
+
if group: # 0 键分组视图:组头=组内最新 created 降序
|
|
174
|
+
gmap = {}
|
|
175
|
+
for tid in order:
|
|
176
|
+
gmap.setdefault(_gkey(tasks[tid]), []).append(tid)
|
|
177
|
+
keys = sorted(gmap, key=lambda k: max(tasks[t].get("created") or 0.0
|
|
178
|
+
for t in gmap[k]), reverse=True)
|
|
179
|
+
for k in keys:
|
|
180
|
+
members = gmap[k]
|
|
181
|
+
rows.append(Row("group", k, title=f"{k} {len(members)}",
|
|
182
|
+
domain="bucket"))
|
|
183
|
+
if not f and bucket is None and k not in gexp:
|
|
184
|
+
continue # 缺省全折叠;过滤/桶视图穿透组折叠(组头恒在)
|
|
185
|
+
for tid in members:
|
|
186
|
+
t = tasks[tid]
|
|
187
|
+
if f and f not in f"{tid} {t.get('title', '')} {t['state']}".lower():
|
|
188
|
+
continue
|
|
189
|
+
if bucket is not None and _bucket(t["state"]) != bucket:
|
|
190
|
+
continue
|
|
191
|
+
_emit(rows, tid)
|
|
192
|
+
return rows
|
|
193
|
+
|
|
194
|
+
for tid in order:
|
|
195
|
+
t = tasks[tid]
|
|
196
|
+
if f and f not in f"{tid} {t.get('title', '')} {t['state']}".lower():
|
|
197
|
+
continue # 过滤穿透折叠:命中行全部照常显示,不看 expanded
|
|
198
|
+
if bucket is not None and _bucket(t["state"]) != bucket:
|
|
199
|
+
continue # 桶视图同语义穿透折叠(口径与计数行同源)
|
|
200
|
+
if not f and bucket is None and not _shown(tid):
|
|
201
|
+
continue
|
|
202
|
+
_emit(rows, tid)
|
|
203
|
+
return rows
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _sel_row(m):
|
|
207
|
+
rows = m.get("rows") or []
|
|
208
|
+
return rows[m["sel"]] if 0 <= m.get("sel", 0) < len(rows) else None
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _skip_bucket(m, down=True, origin=None):
|
|
212
|
+
"""桶头只作分隔:越过后滑到最近任务/事件行;该方向全程无行动行则退回 origin
|
|
213
|
+
(按键前行,防 k 在首个任务行空滑后光标停桶头隐身),缺省=进入时行。"""
|
|
214
|
+
rows = m.get("rows") or []
|
|
215
|
+
d = 1 if down else -1
|
|
216
|
+
origin = m["sel"] if origin is None else origin
|
|
217
|
+
while 0 <= m["sel"] < len(rows) and rows[m["sel"]].kind == "bucket":
|
|
218
|
+
if not 0 <= m["sel"] + d < len(rows):
|
|
219
|
+
break
|
|
220
|
+
m["sel"] += d
|
|
221
|
+
if 0 <= m["sel"] < len(rows) and rows[m["sel"]].kind == "bucket":
|
|
222
|
+
m["sel"] = origin
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def clamp_view(m, visible, nb=0):
|
|
226
|
+
"""视口滚动:保证 sel 落在 [top, top+visible) 窗口内(就地改,无返回值语义)。
|
|
227
|
+
|
|
228
|
+
nb=固顶桶行数:滚动窗只盖 body 行,top 钳在 [nb, len-visible],桶行恒可见。
|
|
229
|
+
"""
|
|
230
|
+
if m["sel"] < m["top"]:
|
|
231
|
+
m["top"] = m["sel"]
|
|
232
|
+
elif m["sel"] >= m["top"] + visible:
|
|
233
|
+
m["top"] = m["sel"] - visible + 1
|
|
234
|
+
m["top"] = min(max(nb, m["top"]),
|
|
235
|
+
max(nb, len(m.get("rows") or []) - visible)) # 行流收缩后回收 top
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def handle_key(m, key):
|
|
239
|
+
"""vi 键位→Action,model 就地演进(less 形态,无模式切换)。
|
|
240
|
+
|
|
241
|
+
键位:j/k 移动(桶头只作分隔不停驻) g/G 跳顶底 Enter/l 明细 h 返回 L 会话 tail d doctor
|
|
242
|
+
x cancel(y/n 确认行) z 折叠/展开(任务子树/组头成员) Z 全折全展 / 过滤(单行输入态)
|
|
243
|
+
1-5 桶视图(同键复原) 0 项目分组模式 r 即刷 q 退出。
|
|
244
|
+
"""
|
|
245
|
+
if m.get("input") is not None: # 过滤单行输入态
|
|
246
|
+
if key in ("\n", "\r", "KEY_ENTER"):
|
|
247
|
+
m["filter"], m["input"], m["buf"] = m["buf"], None, ""
|
|
248
|
+
return Action("apply")
|
|
249
|
+
if key == "\x1b":
|
|
250
|
+
m["input"] = m["buf"] = None
|
|
251
|
+
return Action("noop")
|
|
252
|
+
if key in ("\x7f", "\b", "KEY_BACKSPACE"):
|
|
253
|
+
m["buf"] = m["buf"][:-1]
|
|
254
|
+
elif isinstance(key, str) and len(key) == 1 and key.isprintable():
|
|
255
|
+
m["buf"] += key # 其余功能键忽略
|
|
256
|
+
return Action("noop")
|
|
257
|
+
if m.get("confirm"): # x 破坏性确认行:y 才放行
|
|
258
|
+
tid = m["confirm"]
|
|
259
|
+
m["confirm"] = None
|
|
260
|
+
return Action("cancel", task=tid) if key == "y" else Action("noop")
|
|
261
|
+
r = _sel_row(m)
|
|
262
|
+
if key in ("j", "KEY_DOWN"):
|
|
263
|
+
origin = m["sel"]
|
|
264
|
+
m["sel"] = min(m["sel"] + 1, max(0, len(m.get("rows") or []) - 1))
|
|
265
|
+
_skip_bucket(m, down=True, origin=origin)
|
|
266
|
+
return Action("move")
|
|
267
|
+
if key in ("k", "KEY_UP"):
|
|
268
|
+
origin = m["sel"]
|
|
269
|
+
m["sel"] = max(m["sel"] - 1, 0)
|
|
270
|
+
_skip_bucket(m, down=False, origin=origin)
|
|
271
|
+
return Action("move")
|
|
272
|
+
if key == "g":
|
|
273
|
+
m["sel"] = 0
|
|
274
|
+
_skip_bucket(m, down=True)
|
|
275
|
+
return Action("move")
|
|
276
|
+
if key in ("G", "KEY_END"):
|
|
277
|
+
m["sel"] = max(0, len(m.get("rows") or []) - 1)
|
|
278
|
+
return Action("move")
|
|
279
|
+
if key in ("\n", "\r", "KEY_ENTER", "l"):
|
|
280
|
+
if r and r.kind == "task":
|
|
281
|
+
m["view"] = "detail"
|
|
282
|
+
return Action("detail", task=r.tid)
|
|
283
|
+
return Action("noop")
|
|
284
|
+
if key == "h":
|
|
285
|
+
if m.get("view") != "board":
|
|
286
|
+
m["view"] = "board"
|
|
287
|
+
return Action("back")
|
|
288
|
+
return Action("noop")
|
|
289
|
+
if key == "L":
|
|
290
|
+
if r and r.kind == "task" and r.worker:
|
|
291
|
+
m["view"] = "tail"
|
|
292
|
+
return Action("tail", arg=r.worker)
|
|
293
|
+
return Action("noop")
|
|
294
|
+
if key == "d":
|
|
295
|
+
m["view"] = "doctor"
|
|
296
|
+
return Action("doctor")
|
|
297
|
+
if key == "x":
|
|
298
|
+
if m.get("view") == "board" and r and r.kind == "task":
|
|
299
|
+
m["confirm"] = r.tid
|
|
300
|
+
return Action("confirm", task=r.tid)
|
|
301
|
+
return Action("noop") # 全屏页不武装确认(旁路门)
|
|
302
|
+
if key == "z":
|
|
303
|
+
if m.get("view") == "board" and r and r.kind == "task":
|
|
304
|
+
m["expanded"].symmetric_difference_update({r.tid}) # set 就地 toggle
|
|
305
|
+
return Action("fold", task=r.tid)
|
|
306
|
+
if m.get("view") == "board" and r and r.kind == "group":
|
|
307
|
+
m.setdefault("gexpanded", set()).symmetric_difference_update(
|
|
308
|
+
{r.tid}) # 组展开态就地 toggle(缺省空=全折叠)
|
|
309
|
+
return Action("fold", task=r.tid)
|
|
310
|
+
return Action("noop")
|
|
311
|
+
if key == "Z":
|
|
312
|
+
if m.get("view") == "board":
|
|
313
|
+
if m.get("gexpanded") or m.get("expanded"): # 任一展开→全折
|
|
314
|
+
m.setdefault("gexpanded", set()).clear()
|
|
315
|
+
m.setdefault("expanded", set()).clear()
|
|
316
|
+
else: # 全折→全展(分组+子树两层面)
|
|
317
|
+
m["gexpanded"] = set(m.get("groups") or ())
|
|
318
|
+
m["expanded"] = set(m.get("tids") or ())
|
|
319
|
+
return Action("fold")
|
|
320
|
+
return Action("noop")
|
|
321
|
+
if key == "r":
|
|
322
|
+
return Action("refresh")
|
|
323
|
+
if key == "q":
|
|
324
|
+
return Action("quit")
|
|
325
|
+
if key == "/":
|
|
326
|
+
if m.get("view") == "board":
|
|
327
|
+
m["input"], m["buf"] = "", ""
|
|
328
|
+
return Action("noop")
|
|
329
|
+
if key in ("0", "1", "2", "3", "4", "5"):
|
|
330
|
+
if m.get("view") == "board":
|
|
331
|
+
if key == "0":
|
|
332
|
+
m["proj"] = not m.get("proj") # 项目分组模式开关(桶视图可叠加)
|
|
333
|
+
else:
|
|
334
|
+
cur = _BUCKETS[int(key) - 1]
|
|
335
|
+
m["bucket"] = None if m.get("bucket") == cur else cur # 同键复原
|
|
336
|
+
return Action("apply") # 与过滤 apply 同路:缓存帧即时重建
|
|
337
|
+
return Action("noop")
|
|
338
|
+
return Action("noop") # 非引导键忽略
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
# ---------------- 数据面(agorad HTTP+会话 tail 只读) ----------------
|
|
342
|
+
|
|
343
|
+
def _pointer_home():
|
|
344
|
+
"""~/.agora-home 指针回退(gt-32c051:MCP 壳/install 写入的统一发现桥);缺失/空白=无。"""
|
|
345
|
+
try:
|
|
346
|
+
return ((Path.home() / ".agora-home").read_text(encoding="utf-8")
|
|
347
|
+
.split("\n", 1)[0].strip() or None) # 首行:多行指针不内嵌换行进 home
|
|
348
|
+
except (OSError, ValueError): # ValueError=坏字节(UnicodeDecodeError 非 OSError 族)
|
|
349
|
+
return None
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def discover(home=None):
|
|
353
|
+
"""root 牌+端口:$AGORA_HOME/.agora/{root.token,service.json}。"""
|
|
354
|
+
home = Path(home or os.environ.get("AGORA_HOME") or _pointer_home()
|
|
355
|
+
or Path.home() / "plaza")
|
|
356
|
+
sj = json.loads((home / ".agora" / "service.json").read_text(encoding="utf-8"))
|
|
357
|
+
token = (home / ".agora" / "root.token").read_text(encoding="utf-8").strip()
|
|
358
|
+
return f"http://127.0.0.1:{sj['port']}", token
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _call(base, token, path, body, timeout=5):
|
|
362
|
+
req = urllib.request.Request(
|
|
363
|
+
base + path, data=json.dumps(body).encode(),
|
|
364
|
+
headers={"Content-Type": "application/json", "X-Agora-Token": token},
|
|
365
|
+
method="POST")
|
|
366
|
+
with urllib.request.urlopen(req, timeout=timeout) as r:
|
|
367
|
+
return json.loads(r.read().decode())
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def load_board(base, token):
|
|
371
|
+
"""board 全量:全局五桶+森林序(旧 agorad 无 forest 回退 chain)+逐任务明细。
|
|
372
|
+
|
|
373
|
+
snap["chain"] 实为渲染序(forest 优先);snap["buckets"]=服务端全局五桶
|
|
374
|
+
(旧响应缺失时 build_rows 本地计数兜底)。离线由壳兜住。
|
|
375
|
+
"""
|
|
376
|
+
top = _call(base, token, "/board", {})
|
|
377
|
+
order = top.get("forest", top.get("chain", []))
|
|
378
|
+
tasks, events, details = {}, [], {}
|
|
379
|
+
for tid in order:
|
|
380
|
+
d = _call(base, token, "/board", {"task": tid})
|
|
381
|
+
tasks[tid] = d["task"]
|
|
382
|
+
events.extend(d.get("events", []))
|
|
383
|
+
details[tid] = d
|
|
384
|
+
return {"tasks": tasks, "chain": order,
|
|
385
|
+
"buckets": top.get("buckets")}, events, details
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def try_cancel(base, token, task):
|
|
389
|
+
"""task_cancel 一次并回状态文本:取消失败≠离线(离线只由轮询上报)。"""
|
|
390
|
+
try:
|
|
391
|
+
r = _call(base, token, "/task_cancel", {"task": task})
|
|
392
|
+
return f"cancel {task}: cancelled={r.get('cancelled')}"
|
|
393
|
+
except Exception as e:
|
|
394
|
+
return f"cancel failed: {e}"
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def tail_session(sid, limit=40):
|
|
398
|
+
"""opencode 会话只读 tail:sqlite ro 读 session_message 尾段文本。"""
|
|
399
|
+
data_home = os.environ.get("XDG_DATA_HOME") or str(Path.home() / ".local" / "share")
|
|
400
|
+
p = Path(data_home) / "opencode" / "opencode.db"
|
|
401
|
+
con = sqlite3.connect(f"file:{p}?mode=ro", uri=True, timeout=2)
|
|
402
|
+
try:
|
|
403
|
+
rows = con.execute(
|
|
404
|
+
"SELECT type, data FROM session_message WHERE session_id=?"
|
|
405
|
+
" ORDER BY seq DESC LIMIT ?", (sid, limit)).fetchall()
|
|
406
|
+
finally:
|
|
407
|
+
con.close()
|
|
408
|
+
out = []
|
|
409
|
+
for typ, data in reversed(rows):
|
|
410
|
+
try:
|
|
411
|
+
d = json.loads(data)
|
|
412
|
+
except ValueError:
|
|
413
|
+
continue
|
|
414
|
+
c = d.get("content")
|
|
415
|
+
txt = (" ".join(p.get("text", "") for p in c
|
|
416
|
+
if isinstance(p, dict) and p.get("type") == "text").strip()
|
|
417
|
+
if isinstance(c, list) else str(c or d.get("text") or "").strip())
|
|
418
|
+
if txt:
|
|
419
|
+
out.append(f"[{typ}] {txt}")
|
|
420
|
+
return out or ["(no text messages)"]
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def detail_page(d):
|
|
424
|
+
"""明细页文本:全字段+事件尾+done 结论。"""
|
|
425
|
+
t = d.get("task", {})
|
|
426
|
+
lines = [f"{k}: {t.get(k)}" for k in
|
|
427
|
+
("title", "state", "dispatcher_sid", "worker_sid", "created",
|
|
428
|
+
"last_seen", "env_dir", "project_dir", "fail_streak",
|
|
429
|
+
"chain_depth")]
|
|
430
|
+
lines.append("---- events tail ----")
|
|
431
|
+
lines += [f"{e.get('ts', 0):.0f} {e.get('actor')} {e.get('event')}"
|
|
432
|
+
f" {e.get('kind', '')}".rstrip() for e in d.get("events", [])[-10:]]
|
|
433
|
+
if d.get("done_body"):
|
|
434
|
+
lines += ["---- done verdict ----", str(d["done_body"])[:2000]]
|
|
435
|
+
return lines
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def doctor_page(v, now=None):
|
|
439
|
+
"""doctor 页文本:年龄排行/最后活动排行/僵态候选/环境在盘性。"""
|
|
440
|
+
now = time.time() if now is None else now
|
|
441
|
+
lines = ["---- task age ----"]
|
|
442
|
+
lines += [f"{tid} {rel_time(now - age, now)}" for tid, age in v.get("age_rank", [])]
|
|
443
|
+
lines.append("---- last activity (stalest first) ----")
|
|
444
|
+
lines += [f"{tid} {rel_time(ts, now)}" for tid, ts in v.get("last_seen_rank", [])]
|
|
445
|
+
lines.append("---- stale candidates (in-flight >20min idle, up to owner) ----")
|
|
446
|
+
lines += [f"{tid} {rel_time(ts, now)}" for tid, ts in v.get("stale_candidates", [])]
|
|
447
|
+
lines.append("---- envs ----")
|
|
448
|
+
lines += [f"{e['tid']} {'on-disk' if e['on_disk'] else 'missing'} {e['env_dir']}"
|
|
449
|
+
for e in v.get("env", [])]
|
|
450
|
+
return lines
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
# ---------------- curses 壳 ----------------
|
|
454
|
+
|
|
455
|
+
_PAIR = {"fly": 1, "merged": 2, "blocked": 3, "gone": 4, "warn": 5, "bucket": 6}
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _init_colors():
|
|
459
|
+
if not curses.has_colors():
|
|
460
|
+
return
|
|
461
|
+
curses.start_color()
|
|
462
|
+
curses.use_default_colors()
|
|
463
|
+
for i, fg in enumerate((curses.COLOR_CYAN, curses.COLOR_GREEN,
|
|
464
|
+
curses.COLOR_YELLOW, curses.COLOR_MAGENTA,
|
|
465
|
+
curses.COLOR_RED, curses.COLOR_BLUE), 1):
|
|
466
|
+
curses.init_pair(i, fg, -1)
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _color(domain):
|
|
470
|
+
return curses.color_pair(_PAIR[domain]) if domain in _PAIR and curses.has_colors() else 0
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _row_text(r):
|
|
474
|
+
"""任务/事件行文本:列全部定宽,last_seen 宽度浮动不漂移 repo 列。"""
|
|
475
|
+
return (f"{' ' * r.depth}{r.tid}: {_pad(r.title or '', 32)}"
|
|
476
|
+
f" {_pad(r.state, 13)} {_pad(r.last_seen or '-', 5)}"
|
|
477
|
+
+ (f" {r.repo}" if r.repo else ""))
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def draw_board(stdscr, m, status="", err=None):
|
|
481
|
+
"""一帧板面:头行+固顶桶行+滚动行流(选中反白/着色域)+底行提示+输入/确认行。"""
|
|
482
|
+
h, w = stdscr.getmaxyx()
|
|
483
|
+
stdscr.erase()
|
|
484
|
+
head = f" agora board {status}" + (f" offline: {err}" if err else "")
|
|
485
|
+
stdscr.addnstr(0, 0, head, w - 1, curses.A_REVERSE)
|
|
486
|
+
rows = m["rows"]
|
|
487
|
+
nb = sum(1 for r in rows if r.kind == "bucket")
|
|
488
|
+
visible = max(0, h - 4 - nb) # 矮屏任务区可为 0:宁缺毋挤,不越提示行
|
|
489
|
+
clamp_view(m, visible or 1, nb) # 0 仍钳 top,空切片不画任务行
|
|
490
|
+
for i in range(min(nb, max(0, h - 4))): # 桶行也止于提示行上方
|
|
491
|
+
stdscr.addnstr(i + 1, 0, f"─ {rows[i].title}", w - 1,
|
|
492
|
+
_color(rows[i].domain) | curses.A_BOLD)
|
|
493
|
+
for i, r in enumerate(rows[m["top"]:m["top"] + visible]):
|
|
494
|
+
attr = _color(r.domain)
|
|
495
|
+
if r.kind in ("event", "group"):
|
|
496
|
+
attr |= curses.A_BOLD
|
|
497
|
+
text = r.title if r.kind == "group" else _row_text(r) # 组头=桶行式短题
|
|
498
|
+
if m["top"] + i == m["sel"]:
|
|
499
|
+
attr |= curses.A_REVERSE
|
|
500
|
+
stdscr.addnstr(i + nb + 1, 0, text, w - 1, attr)
|
|
501
|
+
stdscr.addnstr(h - 3, 0,
|
|
502
|
+
" j/k move | g/G jump | Enter/l detail | L tail | d doctor"
|
|
503
|
+
" | x cancel | 1-5 bucket | 0 proj",
|
|
504
|
+
w - 1, curses.A_DIM)
|
|
505
|
+
stdscr.addnstr(h - 2, 0,
|
|
506
|
+
f" r refresh | z/Z fold | q quit | / filter[{m['filter']}]",
|
|
507
|
+
w - 1, curses.A_DIM)
|
|
508
|
+
line = f"/{m['buf']}▌" if m.get("input") is not None else \
|
|
509
|
+
(f"cancel {m['confirm']} ? y/n" if m.get("confirm") else None)
|
|
510
|
+
if line:
|
|
511
|
+
stdscr.addnstr(h - 1, 0, line, w - 1, curses.A_REVERSE)
|
|
512
|
+
stdscr.refresh()
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def draw_page(stdscr, title, lines):
|
|
516
|
+
"""全屏页(明细/doctor/会话 tail):文本流+底行 h 返回。"""
|
|
517
|
+
h, w = stdscr.getmaxyx()
|
|
518
|
+
stdscr.erase()
|
|
519
|
+
stdscr.addnstr(0, 0, f" {title}", w - 1, curses.A_REVERSE)
|
|
520
|
+
for i, ln in enumerate(lines[:h - 2]):
|
|
521
|
+
stdscr.addnstr(i + 1, 0, str(ln), w - 1, 0)
|
|
522
|
+
stdscr.addnstr(h - 1, 0, " h back | q quit", w - 1, curses.A_DIM)
|
|
523
|
+
stdscr.refresh()
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def run(stdscr, base, token):
|
|
527
|
+
"""主循环:画→getch 阻塞 interval(AGORA_BOARD_INTERVAL 可配);按键即时用
|
|
528
|
+
内存模型重画,仅超时 -1 或 r 才 poll 拉 HTTP(避免每键 N+1 卡顿)。"""
|
|
529
|
+
interval = max(0.2, float(os.environ.get("AGORA_BOARD_INTERVAL", "5")))
|
|
530
|
+
curses.curs_set(0)
|
|
531
|
+
_init_colors()
|
|
532
|
+
stdscr.timeout(int(interval * 1000))
|
|
533
|
+
m = {"rows": [], "sel": 0, "top": 0, "view": "board", "confirm": None,
|
|
534
|
+
"input": None, "buf": "", "filter": "", "expanded": set(),
|
|
535
|
+
"bucket": None, "proj": False, "gexpanded": set(),
|
|
536
|
+
"tids": set(), "groups": set()}
|
|
537
|
+
details, snap, events = {}, None, None
|
|
538
|
+
status, err, page = "loading…", None, []
|
|
539
|
+
|
|
540
|
+
def poll():
|
|
541
|
+
"""拉一帧刷新内存模型;失败保旧行置 err(err 语义=轮询离线)。"""
|
|
542
|
+
nonlocal details, status, err, snap, events
|
|
543
|
+
try:
|
|
544
|
+
snap, events, details = load_board(base, token)
|
|
545
|
+
_rebuild()
|
|
546
|
+
err = None # 仅新帧成功才清离线横幅;缓存重建不洗掉 err
|
|
547
|
+
except Exception as e: # 离线/坏帧:保旧行+报因
|
|
548
|
+
err = str(e)
|
|
549
|
+
|
|
550
|
+
def _rebuild():
|
|
551
|
+
"""用当前(缓存)帧按 m['filter'] 重建行+刷新状态头。"""
|
|
552
|
+
nonlocal status
|
|
553
|
+
m["rows"] = build_rows(snap, events, m["filter"], expanded=m["expanded"],
|
|
554
|
+
bucket=m["bucket"], group=m["proj"],
|
|
555
|
+
gexpanded=m["gexpanded"])
|
|
556
|
+
m["tids"] = set(snap.get("tasks", {})) # Z 全展宇宙:tid 与分组键全集
|
|
557
|
+
m["groups"] = {_gkey(t) for t in snap.get("tasks", {}).values()}
|
|
558
|
+
m["sel"] = min(m["sel"], max(0, len(m["rows"]) - 1))
|
|
559
|
+
_skip_bucket(m, down=True)
|
|
560
|
+
status = f"{len(m['rows'])} rows poll {interval:g}s"
|
|
561
|
+
|
|
562
|
+
poll() # 首帧先拉,免空板白等一个周期
|
|
563
|
+
while True:
|
|
564
|
+
if m["view"] == "board":
|
|
565
|
+
draw_board(stdscr, m, status, err)
|
|
566
|
+
else:
|
|
567
|
+
draw_page(stdscr, m["view"], page)
|
|
568
|
+
key = stdscr.getch()
|
|
569
|
+
if key == -1:
|
|
570
|
+
poll() # 到点轮询刷一帧
|
|
571
|
+
continue
|
|
572
|
+
k = curses.keyname(key).decode() if key >= 256 else chr(key)
|
|
573
|
+
a = handle_key(m, k)
|
|
574
|
+
if a.op == "quit":
|
|
575
|
+
return
|
|
576
|
+
if a.op == "cancel":
|
|
577
|
+
msg = try_cancel(base, token, a.task)
|
|
578
|
+
poll() # cancel 本就发 HTTP,顺势刷一帧
|
|
579
|
+
status = msg # 保住取消回执不被轮询状态覆盖
|
|
580
|
+
elif a.op == "refresh":
|
|
581
|
+
poll() # r 手动刷:立即拉一帧
|
|
582
|
+
elif a.op in ("apply", "fold"): # 过滤回车/z 折叠:用缓存帧即时重建,零 HTTP
|
|
583
|
+
if snap is not None:
|
|
584
|
+
try:
|
|
585
|
+
_rebuild()
|
|
586
|
+
except Exception as e: # 与 poll 同口径:坏帧保旧行+报因
|
|
587
|
+
err = str(e)
|
|
588
|
+
elif a.op == "detail":
|
|
589
|
+
page = detail_page(details.get(a.task, {"task": {}, "events": []}))
|
|
590
|
+
elif a.op == "doctor":
|
|
591
|
+
try:
|
|
592
|
+
page = doctor_page(_call(base, token, "/doctor", {}))
|
|
593
|
+
except Exception as e:
|
|
594
|
+
page = [f"doctor unavailable: {e}"]
|
|
595
|
+
elif a.op == "tail":
|
|
596
|
+
try:
|
|
597
|
+
page = tail_session(a.arg)
|
|
598
|
+
except Exception as e: # 只读降级为提示行
|
|
599
|
+
page = [f"session tail unavailable: {e}"]
|
|
600
|
+
elif a.op == "back":
|
|
601
|
+
page = []
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def selftest():
|
|
605
|
+
"""smoke:真连一次+curses 画一帧即退(0=帧成)。"""
|
|
606
|
+
base, token = discover()
|
|
607
|
+
err = None
|
|
608
|
+
try:
|
|
609
|
+
snap, events, _ = load_board(base, token)
|
|
610
|
+
rows = build_rows(snap, events)
|
|
611
|
+
except Exception as e:
|
|
612
|
+
rows, err = [], str(e)
|
|
613
|
+
m = {"rows": rows, "sel": 0, "top": 0, "view": "board", "confirm": None,
|
|
614
|
+
"input": None, "buf": "", "filter": "", "expanded": set()}
|
|
615
|
+
|
|
616
|
+
def once(stdscr):
|
|
617
|
+
curses.curs_set(0)
|
|
618
|
+
_init_colors()
|
|
619
|
+
draw_board(stdscr, m, "selftest", err)
|
|
620
|
+
|
|
621
|
+
curses.wrapper(once)
|
|
622
|
+
print(f"selftest ok rows={len(m['rows'])}" + (f" err={err}" if err else ""))
|
|
623
|
+
return 0
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def _tty_setup():
|
|
627
|
+
""" curses 前置:locale(CJK 宽字符/中文过滤输入)+Esc 快速应答。"""
|
|
628
|
+
locale.setlocale(locale.LC_ALL, "")
|
|
629
|
+
os.environ.setdefault("ESCDELAY", "25")
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def main(argv=None):
|
|
633
|
+
argv = sys.argv[1:] if argv is None else argv
|
|
634
|
+
try:
|
|
635
|
+
_tty_setup()
|
|
636
|
+
if "--selftest" in argv:
|
|
637
|
+
return selftest()
|
|
638
|
+
base, token = discover()
|
|
639
|
+
curses.wrapper(run, base, token)
|
|
640
|
+
return 0
|
|
641
|
+
except KeyboardInterrupt:
|
|
642
|
+
return 0
|
|
643
|
+
except Exception as e:
|
|
644
|
+
print(f"board_tui: {e}", file=sys.stderr)
|
|
645
|
+
return 2
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
if __name__ == "__main__":
|
|
649
|
+
sys.exit(main())
|