dsh-agent-lines 0.3.5 → 0.3.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/CHANGELOG.en.md +17 -1
- package/CHANGELOG.md +17 -1
- package/CODE_OF_CONDUCT.en.md +0 -0
- package/CODE_OF_CONDUCT.md +0 -0
- package/CONTRIBUTING.en.md +0 -0
- package/CONTRIBUTING.md +0 -0
- package/LICENSE +0 -0
- package/NOTICE +0 -0
- package/README.en.md +18 -3
- package/README.md +18 -3
- package/SECURITY.en.md +0 -0
- package/SECURITY.md +0 -0
- package/bin/dsh-agent-lines.js +0 -0
- package/cordis.patch.yml +0 -0
- package/docs/ARCHITECTURE.en.md +0 -0
- package/docs/ARCHITECTURE.md +0 -0
- package/docs/DEPLOY.en.md +0 -0
- package/docs/DEPLOY.md +0 -0
- package/docs/PROTOCOL.en.md +16 -1
- package/docs/PROTOCOL.md +16 -1
- package/docs/SECURITY.en.md +0 -0
- package/docs/SECURITY.md +0 -0
- package/docs/assets/first-use.gif +0 -0
- package/docs/assets/social-preview.png +0 -0
- package/docs/en/index.html +4 -4
- package/docs/index.html +4 -4
- package/docs/market-index.json +3 -3
- package/examples/dashboard/README.md +16 -0
- package/examples/market-index.json +3 -3
- package/lib/delivery.js +0 -0
- package/lib/index.js +174 -11
- package/lib/prompt.js +0 -0
- package/lib/protocol.js +0 -0
- package/lib/store.js +162 -4
- package/lib/tools.js +128 -2
- package/lib/transport.js +0 -0
- package/package.json +1 -1
package/CHANGELOG.en.md
CHANGED
|
@@ -2,6 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
English | [中文](CHANGELOG.md)
|
|
4
4
|
|
|
5
|
+
## 0.3.7 (2026-11-09)
|
|
6
|
+
|
|
7
|
+
- Added line_handoff: structured handoff snapshots (JSON/Markdown), no model call.
|
|
8
|
+
- line_status / line_list now expose lifecycle/title/generation/pending/lastActivityAt/oldSessionId.
|
|
9
|
+
- rebind sends a noReply handoff notice to the new line; replace reuses the onboarding message.
|
|
10
|
+
- list --json can feed an external dashboard; added examples/dashboard notes.
|
|
11
|
+
- Tests 33/33 pass.
|
|
12
|
+
|
|
13
|
+
## 0.3.6 (2026-11-09)
|
|
14
|
+
|
|
15
|
+
- Added line lifecycle: line_retire, line_rebind, line_create replace:true, line_rename.
|
|
16
|
+
- Schema v7: lines.title/lifecycle/generation/rebind_at/rebind_from + line_events audit; additive migration from v6.
|
|
17
|
+
- Backlog migration: queued/claimed migrate; delivered/acked are never re-delivered; migrate:none fails closed when backlog is non-empty.
|
|
18
|
+
- Persistent titles: line_create/line_rename write lines.title and survive restarts.
|
|
19
|
+
- Tests 32/32 pass; existing 0.3.5 behavior unchanged.
|
|
20
|
+
|
|
5
21
|
## 0.3.5 (2026-11-05)
|
|
6
22
|
|
|
7
23
|
- Integrated P0/P1 bootstrap: line_create queues an onboarding message by default, adds bootstrapOnCreate, and line_status/line_list expose materialized / visibleInSidebar / bootstrapQueued.
|
|
@@ -69,4 +85,4 @@ English | [中文](CHANGELOG.md)
|
|
|
69
85
|
- Explicit `delivered` vs `acked`; at-least-once with effectively-once Session log semantics.
|
|
70
86
|
- Local filesystem check (NFS/CIFS/SMB/FUSE refused).
|
|
71
87
|
|
|
72
|
-
Not included: HTTP transport, Web UI, Postgres adapter, automatic process wake.
|
|
88
|
+
Not included: HTTP transport, Web UI, Postgres adapter, automatic process wake.
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
[English](CHANGELOG.en.md) | 中文
|
|
4
4
|
|
|
5
|
+
## 0.3.7 (2026-11-09)
|
|
6
|
+
|
|
7
|
+
- 新增 line_handoff:结构化交接快照(JSON/Markdown),不调用模型。
|
|
8
|
+
- line_status / line_list 增加 lifecycle/title/generation/pending/lastActivityAt/oldSessionId。
|
|
9
|
+
- rebind 后给新线发 noReply 交接通知;replace 复用 onboarding 消息。
|
|
10
|
+
- list --json 可直接喂给外部看板;新增 examples/dashboard 说明。
|
|
11
|
+
- 测试 33/33 通过。
|
|
12
|
+
|
|
13
|
+
## 0.3.6 (2026-11-09)
|
|
14
|
+
|
|
15
|
+
- 新增线生命周期:line_retire、line_rebind、line_create replace:true、line_rename。
|
|
16
|
+
- schema v7:lines.title/lifecycle/generation/rebind_at/rebind_from + line_events 审计;旧 DB additive 迁移。
|
|
17
|
+
- 积压迁移:queued/claimed 迁移,delivered/acked 不重投;migrate:none 在 backlog 非空时 fail-closed。
|
|
18
|
+
- 标题持久化:line_create/line_rename 写 lines.title,重启保留。
|
|
19
|
+
- 测试 32/32 通过;旧 0.3.5 行为不变。
|
|
20
|
+
|
|
5
21
|
## 0.3.5 (2026-11-05)
|
|
6
22
|
|
|
7
23
|
- 集成 P0/P1 bootstrap:line_create 默认排队 onboarding 消息,新增 bootstrapOnCreate,line_status/line_list 暴露 materialized / visibleInSidebar / bootstrapQueued。
|
|
@@ -68,4 +84,4 @@
|
|
|
68
84
|
- 显式区分 delivered 和 acked;至少一次投递,Session 日志有效一次。
|
|
69
85
|
- 本地文件系统检查(拒绝 NFS/CIFS/SMB/FUSE)。
|
|
70
86
|
|
|
71
|
-
未包含:HTTP transport、Web UI、Postgres adapter、自动拉起进程。
|
|
87
|
+
未包含:HTTP transport、Web UI、Postgres adapter、自动拉起进程。
|
package/CODE_OF_CONDUCT.en.md
CHANGED
|
File without changes
|
package/CODE_OF_CONDUCT.md
CHANGED
|
File without changes
|
package/CONTRIBUTING.en.md
CHANGED
|
File without changes
|
package/CONTRIBUTING.md
CHANGED
|
File without changes
|
package/LICENSE
CHANGED
|
File without changes
|
package/NOTICE
CHANGED
|
File without changes
|
package/README.en.md
CHANGED
|
@@ -36,7 +36,7 @@ The script picks the DSH Web profile, installs the plugin, and verifies agent-li
|
|
|
36
36
|
|
|
37
37
|
Offline or when npm is unavailable, use the local tarball:
|
|
38
38
|
|
|
39
|
-
bash install.sh ./dsh-agent-lines-0.3.
|
|
39
|
+
bash install.sh ./dsh-agent-lines-0.3.7.tgz
|
|
40
40
|
|
|
41
41
|
After install and restart, it works without a config. When you want a fixed manager/worker setup, paste the "Manager config" block below into the file printed by the installer.
|
|
42
42
|
|
|
@@ -137,7 +137,11 @@ Tools:
|
|
|
137
137
|
| `line_status` | Read-only: current Session binding, manager Session live/persisted, lines, repair command; visible even when unbound |
|
|
138
138
|
| `line_claim_manager` | Bind this live root Session as manager; requires `allowClaim: true` and `confirm: "bind-manager"`; global, no shell needed |
|
|
139
139
|
| `line_list` | List lines, status, unread counts, current line and role |
|
|
140
|
-
| `line_create` | Manager only: create a
|
|
140
|
+
| `line_create` | Manager only: create a line; same label is idempotent; replace:true swaps the label to a fresh Session |
|
|
141
|
+
| `line_handoff` | Manager only: structured handoff snapshot (JSON/Markdown), no model call |
|
|
142
|
+
| `line_retire` | Manager only: soft-retire; reject new sends, keep Session and history |
|
|
143
|
+
| `line_rebind` | Manager only: atomically switch a line to a new live Session, optionally migrating backlog |
|
|
144
|
+
| `line_rename` | Manager only: rename the sidebar title and persist it in lines.db |
|
|
141
145
|
| `line_move_workspace` | Manager only: attach an existing line Session to a workspace whose path exactly matches the Session cwd |
|
|
142
146
|
| `line_send` | Send to `line` or `agent#line`; optional `idempotencyKey`, `replyTo`, `noReply`, `forceDistinct`, `ttlMs` |
|
|
143
147
|
| `line_inbox` | Read delivered messages for the current line |
|
|
@@ -147,6 +151,17 @@ Line tools other than `line_status` and `line_claim_manager` are registered only
|
|
|
147
151
|
|
|
148
152
|
`line_create` creates an ordinary DSH Session in the manager workspace (or in the manager cwd when no workspace exists). It does **not** create a separate OS process, profile, or port; all lines in that call share the DSH process. It also does not transfer an existing Session.
|
|
149
153
|
|
|
154
|
+
### Replacing a line when context is full
|
|
155
|
+
|
|
156
|
+
A line does not inherit the old Session context. Recommended flow:
|
|
157
|
+
|
|
158
|
+
1. Have the old line write a handoff file (line_handoff is available from 0.3.7);
|
|
159
|
+
2. The manager calls `line_create({ label, replace: true, force: true, reason: "context full" })`;
|
|
160
|
+
3. The plugin creates a clean Session, atomically swaps the same label, and marks the old Session retired but keeps it;
|
|
161
|
+
4. queued/claimed messages migrate to the new line; delivered/acked stay on the old Session and are never re-delivered.
|
|
162
|
+
|
|
163
|
+
With `migrate: "none"`, a non-empty queue returns `LINE_BACKLOG_NOT_EMPTY` instead of silently switching.
|
|
164
|
+
|
|
150
165
|
Created lines are recorded with `created_by` and restored by the manager on process startup. line_create uses the manager's workspace (workspaceId, so DSH attaches the Session) when one exists; otherwise it falls back to the manager cwd and the line is ungrouped. Pass `workspace` (existing path or id) to create in another workspace. A line whose Session was never materialized may fail on first delivery with `session/not-found`; send it a task (or recreate it) to persist the Session.
|
|
151
166
|
|
|
152
167
|
CLI:
|
|
@@ -274,4 +289,4 @@ See [CONTRIBUTING.en.md](CONTRIBUTING.en.md); code of conduct [CODE_OF_CONDUCT.e
|
|
|
274
289
|
|
|
275
290
|
## License
|
|
276
291
|
|
|
277
|
-
Apache-2.0. See [LICENSE](LICENSE).
|
|
292
|
+
Apache-2.0. See [LICENSE](LICENSE).
|
package/README.md
CHANGED
|
@@ -36,7 +36,7 @@ DSH 的 super agent 平替插件。
|
|
|
36
36
|
|
|
37
37
|
离线或 npm 不可用时,用本地 tarball:
|
|
38
38
|
|
|
39
|
-
bash install.sh ./dsh-agent-lines-0.3.
|
|
39
|
+
bash install.sh ./dsh-agent-lines-0.3.7.tgz
|
|
40
40
|
|
|
41
41
|
安装并重启后,不写配置也可以直接使用;需要固定管理线/worker 时,再把下面“管理线配置”粘贴到脚本打印的配置文件。
|
|
42
42
|
|
|
@@ -137,7 +137,11 @@ line_create({ label: "ops", role: "运维" })
|
|
|
137
137
|
| `line_status` | 只读:当前会话绑定、manager Session 是否存在/live、线列表、修复命令;未绑定也可见 |
|
|
138
138
|
| `line_claim_manager` | 把当前 live 根 Session 绑成 manager;需要 `allowClaim: true` 和 `confirm: "bind-manager"`;全局工具,不需要 shell |
|
|
139
139
|
| `line_list` | 列出线、状态、未读数、当前线和 role |
|
|
140
|
-
| `line_create` |
|
|
140
|
+
| `line_create` | 仅管理线:创建新线;同 label 幂等;传 replace:true 可给同 label 换新 Session |
|
|
141
|
+
| `line_handoff` | 仅管理线:生成结构化交接单(JSON/Markdown),不调模型 |
|
|
142
|
+
| `line_retire` | 仅管理线:软下线;拒绝新发送,保留 Session/历史 |
|
|
143
|
+
| `line_rebind` | 仅管理线:把线原子切到新的 live Session,可迁移积压 |
|
|
144
|
+
| `line_rename` | 仅管理线:改侧栏标题,持久化到 lines.db |
|
|
141
145
|
| `line_move_workspace` | 仅管理线:把已有线的 Session 挂到路径完全匹配的工作区 |
|
|
142
146
|
| `line_send` | 发到 `line` 或 `agent#line`;可选 `idempotencyKey`、`replyTo`、`noReply`、`forceDistinct`、`ttlMs` |
|
|
143
147
|
| `line_inbox` | 读当前线已投递消息 |
|
|
@@ -147,6 +151,17 @@ line_create({ label: "ops", role: "运维" })
|
|
|
147
151
|
|
|
148
152
|
`line_create` 在管理线所属工作区(没有工作区时回退当前目录)创建普通 DSH Session;它**不会**创建独立 OS 进程、profile 或端口,也不转移已有 Session。
|
|
149
153
|
|
|
154
|
+
### 上下文满时换人
|
|
155
|
+
|
|
156
|
+
线不会自动继承旧 Session 的上下文。推荐流程:
|
|
157
|
+
|
|
158
|
+
1. 让旧线写交接单(0.3.7 起可用 `line_handoff`);
|
|
159
|
+
2. 管理线调用 `line_create({ label, replace: true, force: true, reason: "context full" })`;
|
|
160
|
+
3. 插件创建干净 Session、同 label 原子切换,旧 Session 标记 retired 但保留;
|
|
161
|
+
4. queued/claimed 消息迁移到新线;delivered/acked 留在旧 Session,不重投。
|
|
162
|
+
|
|
163
|
+
`migrate: "none"` 时若还有 queued/claimed 会直接报 `LINE_BACKLOG_NOT_EMPTY`,不会静默切走。
|
|
164
|
+
|
|
150
165
|
创建的线会记录 `created_by`,进程重启时由管理线恢复。line_create 会解析管理线所属工作区并传 workspaceId(DSH 会自动 attach),所以新线出现在该工作区而不是“未分组”;如果管理线本身不在工作区,则回退 cwd,线保持未分组。传 `workspace`(已有路径或 id)可指定其他工作区。若某条线的 Session 还从未落盘,首次投递可能 `session/not-found`;给它发一条任务(或重新创建)即可让它持久化。
|
|
151
166
|
|
|
152
167
|
CLI:
|
|
@@ -267,4 +282,4 @@ claim 不会对每个新会话自动执行:需要用户明确要求,或运
|
|
|
267
282
|
|
|
268
283
|
## 许可
|
|
269
284
|
|
|
270
|
-
Apache-2.0,见 [LICENSE](LICENSE)。
|
|
285
|
+
Apache-2.0,见 [LICENSE](LICENSE)。
|
package/SECURITY.en.md
CHANGED
|
File without changes
|
package/SECURITY.md
CHANGED
|
File without changes
|
package/bin/dsh-agent-lines.js
CHANGED
|
File without changes
|
package/cordis.patch.yml
CHANGED
|
File without changes
|
package/docs/ARCHITECTURE.en.md
CHANGED
|
File without changes
|
package/docs/ARCHITECTURE.md
CHANGED
|
File without changes
|
package/docs/DEPLOY.en.md
CHANGED
|
File without changes
|
package/docs/DEPLOY.md
CHANGED
|
File without changes
|
package/docs/PROTOCOL.en.md
CHANGED
|
@@ -98,6 +98,21 @@ The new line is an ordinary DSH Session in the same DSH process; no OS process,
|
|
|
98
98
|
|
|
99
99
|
`line_move_workspace({ line, workspace })` attaches an existing line Session to an existing workspace. DSH's `attachSession` validates that the Session's stored cwd resolves exactly to the workspace path; a different directory fails with `WORKSPACE_ATTACH_FAILED`. It does not move a Session between different directories. If Session creation succeeds but registration fails, the error carries `detail.sessionId` and the Session is not deleted automatically.
|
|
100
100
|
|
|
101
|
+
## Line lifecycle (0.3.6)
|
|
102
|
+
|
|
103
|
+
line_retire({ line, reason? })
|
|
104
|
+
line_rebind({ line, sessionId, migrate?: "queued" | "none", reason? })
|
|
105
|
+
line_create({ label, replace: true, force?, migrate?, title?, reason? })
|
|
106
|
+
line_handoff({ line, format?: "md" | "json", limit?: number })
|
|
107
|
+
line_rename({ line, title })
|
|
108
|
+
|
|
109
|
+
- retired: line_send returns LINE_RETIRED; the Session and history are kept.
|
|
110
|
+
- rebind: atomically switches lines.session_id and increments generation; the old Session is never deleted.
|
|
111
|
+
- migrate=queued: queued/claimed messages move to the new Session; delivered/acked are never re-delivered.
|
|
112
|
+
- migrate=none: returns LINE_BACKLOG_NOT_EMPTY if the backlog is non-empty.
|
|
113
|
+
- replace: creates a fresh Session for the same label and rebinds; an active line requires force + reason.
|
|
114
|
+
- line_events records title/retire/rebind audit; titles survive restarts.
|
|
115
|
+
|
|
101
116
|
## Echo and reply depth
|
|
102
117
|
|
|
103
118
|
- `rootMessageId` groups a reply chain; a root message has null.
|
|
@@ -141,4 +156,4 @@ The new line is an ordinary DSH Session in the same DSH process; no OS process,
|
|
|
141
156
|
| `LINE_NO_REPLY` | Reply to a noReply message |
|
|
142
157
|
| `LINE_REPLY_DEPTH_EXCEEDED` | Reply chain exceeds maxReplyDepth |
|
|
143
158
|
| `LINE_REPLY_TARGET_NOT_FOUND` | replyTo names an unknown message |
|
|
144
|
-
| `session/writer-held` (DSH) | Another DSH process holds the target Session's writer lock; close the other view or use a dedicated Session |
|
|
159
|
+
| `session/writer-held` (DSH) | Another DSH process holds the target Session's writer lock; close the other view or use a dedicated Session |
|
package/docs/PROTOCOL.md
CHANGED
|
@@ -90,6 +90,21 @@ line_create 通过 workspaceRegistry.resolveByPath(managerCwd) 解析管理线
|
|
|
90
90
|
|
|
91
91
|
line_move_workspace({ line, workspace }) 把已有线 Session 挂到已有工作区。DSH 的 attachSession 要求 Session 存储的 cwd 与工作区 path 完全一致;不同目录会 WORKSPACE_ATTACH_FAILED,不会迁移 Session。如果 Session 创建成功但注册失败,错误里带 detail.sessionId,Session 不会自动删除。
|
|
92
92
|
|
|
93
|
+
## 线生命周期(0.3.6)
|
|
94
|
+
|
|
95
|
+
line_retire({ line, reason? })
|
|
96
|
+
line_rebind({ line, sessionId, migrate?: "queued" | "none", reason? })
|
|
97
|
+
line_create({ label, replace: true, force?, migrate?, title?, reason? })
|
|
98
|
+
line_handoff({ line, format?: "md" | "json", limit?: number })
|
|
99
|
+
line_rename({ line, title })
|
|
100
|
+
|
|
101
|
+
- retired:line_send 返回 LINE_RETIRED;Session 和消息历史保留。
|
|
102
|
+
- rebind:原子切换 lines.session_id,generation+1;旧 Session 不删除。
|
|
103
|
+
- migrate=queued:queued/claimed 迁移到新 Session;delivered/acked 不重投。
|
|
104
|
+
- migrate=none:backlog 非空时返回 LINE_BACKLOG_NOT_EMPTY。
|
|
105
|
+
- replace:同 label 创建新 Session 并 rebind;active 线需 force + reason。
|
|
106
|
+
- line_events 记录 title/retire/rebind 审计;title 持久化,重启保留。
|
|
107
|
+
|
|
93
108
|
## 回声与回复深度
|
|
94
109
|
|
|
95
110
|
- rootMessageId 标识回复链;根消息为 null。
|
|
@@ -133,4 +148,4 @@ line_move_workspace({ line, workspace }) 把已有线 Session 挂到已有工作
|
|
|
133
148
|
| LINE_NO_REPLY | 回复 noReply 消息 |
|
|
134
149
|
| LINE_REPLY_DEPTH_EXCEEDED | 回复链超过 maxReplyDepth |
|
|
135
150
|
| LINE_REPLY_TARGET_NOT_FOUND | replyTo 指向未知消息 |
|
|
136
|
-
| session/writer-held (DSH) | 另一个 DSH 进程持有目标 Session 的 writer 锁;关闭另一个视图或使用专用 Session |
|
|
151
|
+
| session/writer-held (DSH) | 另一个 DSH 进程持有目标 Session 的 writer 锁;关闭另一个视图或使用专用 Session |
|
package/docs/SECURITY.en.md
CHANGED
|
File without changes
|
package/docs/SECURITY.md
CHANGED
|
File without changes
|
|
File without changes
|
|
File without changes
|
package/docs/en/index.html
CHANGED
|
@@ -43,7 +43,7 @@ footer{padding:34px 0 48px;color:var(--muted);font-size:12px}
|
|
|
43
43
|
<div class="wrap">
|
|
44
44
|
<header>
|
|
45
45
|
<a class="brand" href="../"><span class="mark"></span>dsh-agent-lines</a>
|
|
46
|
-
<nav><a href="../">中文</a><a href="https://github.com/Lencen/dsh-agent-lines">GitHub</a><a href="https://github.com/Lencen/dsh-agent-lines/releases/tag/v0.3.
|
|
46
|
+
<nav><a href="../">中文</a><a href="https://github.com/Lencen/dsh-agent-lines">GitHub</a><a href="https://github.com/Lencen/dsh-agent-lines/releases/tag/v0.3.7">Release</a></nav>
|
|
47
47
|
</header>
|
|
48
48
|
<main>
|
|
49
49
|
<section class="hero" style="border:0;padding-bottom:22px">
|
|
@@ -54,7 +54,7 @@ footer{padding:34px 0 48px;color:var(--muted);font-size:12px}
|
|
|
54
54
|
<div class="install">curl -fsSL https://raw.githubusercontent.com/Lencen/dsh-agent-lines/main/install.sh -o install.sh && bash install.sh</div>
|
|
55
55
|
<div class="actions">
|
|
56
56
|
<a class="btn primary" href="https://github.com/Lencen/dsh-agent-lines">GitHub repository</a>
|
|
57
|
-
<a class="btn" href="https://github.com/Lencen/dsh-agent-lines/releases/tag/v0.3.
|
|
57
|
+
<a class="btn" href="https://github.com/Lencen/dsh-agent-lines/releases/tag/v0.3.7">Download 0.3.7</a>
|
|
58
58
|
</div>
|
|
59
59
|
</div>
|
|
60
60
|
<div class="shot">
|
|
@@ -92,8 +92,8 @@ research (调研), architecture (架构), uiux (UIUX), dev (开发), test (测
|
|
|
92
92
|
After the team is ready, wait for my first task. Do not start by yourself.</pre>
|
|
93
93
|
<p style="color:var(--muted);font-size:14px">Restart DSH Web after install or update. Technical details, protocol, and limits: <a href="https://github.com/Lencen/dsh-agent-lines">README / architecture</a>. Community plugin, not an official DeepSeek product.</p>
|
|
94
94
|
</section>
|
|
95
|
-
<footer>dsh-agent-lines 0.3.
|
|
95
|
+
<footer>dsh-agent-lines 0.3.7 · Apache-2.0 · Community plugin for DeepSeek Harness. Not an official DeepSeek product.</footer>
|
|
96
96
|
</main>
|
|
97
97
|
</div>
|
|
98
98
|
</body>
|
|
99
|
-
</html>
|
|
99
|
+
</html>
|
package/docs/index.html
CHANGED
|
@@ -43,7 +43,7 @@ footer{padding:34px 0 48px;color:var(--muted);font-size:12px}
|
|
|
43
43
|
<div class="wrap">
|
|
44
44
|
<header>
|
|
45
45
|
<a class="brand" href="./"><span class="mark"></span>dsh-agent-lines</a>
|
|
46
|
-
<nav><a href="en/index.html">English</a><a href="https://github.com/Lencen/dsh-agent-lines">GitHub</a><a href="https://github.com/Lencen/dsh-agent-lines/releases/tag/v0.3.
|
|
46
|
+
<nav><a href="en/index.html">English</a><a href="https://github.com/Lencen/dsh-agent-lines">GitHub</a><a href="https://github.com/Lencen/dsh-agent-lines/releases/tag/v0.3.7">Release</a></nav>
|
|
47
47
|
</header>
|
|
48
48
|
<main>
|
|
49
49
|
<section class="hero" style="border:0;padding-bottom:22px">
|
|
@@ -54,7 +54,7 @@ footer{padding:34px 0 48px;color:var(--muted);font-size:12px}
|
|
|
54
54
|
<div class="install">curl -fsSL https://raw.githubusercontent.com/Lencen/dsh-agent-lines/main/install.sh -o install.sh && bash install.sh</div>
|
|
55
55
|
<div class="actions">
|
|
56
56
|
<a class="btn primary" href="https://github.com/Lencen/dsh-agent-lines">GitHub 仓库</a>
|
|
57
|
-
<a class="btn" href="https://github.com/Lencen/dsh-agent-lines/releases/tag/v0.3.
|
|
57
|
+
<a class="btn" href="https://github.com/Lencen/dsh-agent-lines/releases/tag/v0.3.7">下载 0.3.7</a>
|
|
58
58
|
</div>
|
|
59
59
|
</div>
|
|
60
60
|
<div class="shot">
|
|
@@ -92,8 +92,8 @@ research(调研)、architecture(架构)、uiux(UIUX)、dev(开发
|
|
|
92
92
|
团队建好后先待命,不要自动开工,等我发第一个任务。</pre>
|
|
93
93
|
<p style="color:var(--muted);font-size:14px">安装或更新后需要重启 DSH Web。技术细节、协议和限制见 <a href="https://github.com/Lencen/dsh-agent-lines">README / 架构文档</a>。社区插件,不是 DeepSeek 官方产品。</p>
|
|
94
94
|
</section>
|
|
95
|
-
<footer>dsh-agent-lines 0.3.
|
|
95
|
+
<footer>dsh-agent-lines 0.3.7 · Apache-2.0 · Community plugin for DeepSeek Harness. Not an official DeepSeek product.</footer>
|
|
96
96
|
</main>
|
|
97
97
|
</div>
|
|
98
98
|
</body>
|
|
99
|
-
</html>
|
|
99
|
+
</html>
|
package/docs/market-index.json
CHANGED
|
@@ -50,11 +50,11 @@
|
|
|
50
50
|
]
|
|
51
51
|
},
|
|
52
52
|
"dist-tags": {
|
|
53
|
-
"latest": "0.3.
|
|
53
|
+
"latest": "0.3.7"
|
|
54
54
|
},
|
|
55
55
|
"versions": [
|
|
56
56
|
{
|
|
57
|
-
"version": "0.3.
|
|
57
|
+
"version": "0.3.7",
|
|
58
58
|
"publishTimestamp": 1793771000,
|
|
59
59
|
"source": {
|
|
60
60
|
"type": "npm"
|
|
@@ -66,4 +66,4 @@
|
|
|
66
66
|
]
|
|
67
67
|
}
|
|
68
68
|
]
|
|
69
|
-
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Dashboard example
|
|
2
|
+
|
|
3
|
+
The plugin deliberately does not run an HTTP server. Read the same SQLite queue with:
|
|
4
|
+
|
|
5
|
+
~~~bash
|
|
6
|
+
dsh-agent-lines --json list
|
|
7
|
+
~~~
|
|
8
|
+
|
|
9
|
+
Fields: label/title/role/lifecycle/generation/pending/unread/lastActivityAt/lease.
|
|
10
|
+
The Surface feedback dashboard (refresh-dashboard.js) can consume this JSON and keep its own directory scan.
|
|
11
|
+
|
|
12
|
+
For a handoff snapshot before replacing a line:
|
|
13
|
+
|
|
14
|
+
~~~text
|
|
15
|
+
line_handoff({ line: "writer", format: "json" })
|
|
16
|
+
~~~
|
|
@@ -50,11 +50,11 @@
|
|
|
50
50
|
]
|
|
51
51
|
},
|
|
52
52
|
"dist-tags": {
|
|
53
|
-
"latest": "0.3.
|
|
53
|
+
"latest": "0.3.7"
|
|
54
54
|
},
|
|
55
55
|
"versions": [
|
|
56
56
|
{
|
|
57
|
-
"version": "0.3.
|
|
57
|
+
"version": "0.3.7",
|
|
58
58
|
"publishTimestamp": 1793771000,
|
|
59
59
|
"source": {
|
|
60
60
|
"type": "npm"
|
|
@@ -66,4 +66,4 @@
|
|
|
66
66
|
]
|
|
67
67
|
}
|
|
68
68
|
]
|
|
69
|
-
}
|
|
69
|
+
}
|
package/lib/delivery.js
CHANGED
|
File without changes
|
package/lib/index.js
CHANGED
|
@@ -34,7 +34,8 @@ function normalizeLine(entry, agentId, index) {
|
|
|
34
34
|
const sessionId = String(entry.sessionId ?? '').trim()
|
|
35
35
|
if (sessionId.length === 0) throw new LineError('LINE_INVALID_CONFIG', `lines[${index}].sessionId is required`)
|
|
36
36
|
const role = entry.role === undefined || entry.role === null ? null : String(entry.role).trim() || null
|
|
37
|
-
|
|
37
|
+
const title = entry.title === undefined || entry.title === null ? null : String(entry.title).trim() || null
|
|
38
|
+
return { agentId, label, sessionId, lineId: lineId(agentId, label), role, title }
|
|
38
39
|
}
|
|
39
40
|
|
|
40
41
|
/** Validate and normalize plugin config. Exported for tests and the CLI. */
|
|
@@ -131,7 +132,7 @@ export async function apply(ctx, rawConfig) {
|
|
|
131
132
|
if (def === undefined || bindings.has(agent.session.id)) return
|
|
132
133
|
const binding = {
|
|
133
134
|
prompt: bindAgentPrompt(agent, def),
|
|
134
|
-
tools: registerToolsForAgent(agent, def, { config, store, lineDefs, createLine, moveLineWorkspace }),
|
|
135
|
+
tools: registerToolsForAgent(agent, def, { config, store, lineDefs, createLine, moveLineWorkspace, retireLine, rebindLine, renameLine }),
|
|
135
136
|
}
|
|
136
137
|
bindings.set(agent.session.id, binding)
|
|
137
138
|
}
|
|
@@ -147,10 +148,21 @@ export async function apply(ctx, rawConfig) {
|
|
|
147
148
|
}
|
|
148
149
|
}
|
|
149
150
|
|
|
151
|
+
const renameTitle = async (def) => {
|
|
152
|
+
if (def.sessionId === undefined || def.sessionId === '') return
|
|
153
|
+
const title = String(def.title || def.role || def.label || 'agent line').slice(0, 120)
|
|
154
|
+
try {
|
|
155
|
+
await ctx.sessionController?.rename?.({ sessionId: def.sessionId, title })
|
|
156
|
+
} catch (error) {
|
|
157
|
+
ctx.logger?.warn?.('agent-lines: rename %s failed: %s', def.label, String(error))
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
150
161
|
const startLoop = (def) => {
|
|
151
162
|
const loop = new DeliveryLoop({ ctx, config: loopConfig(config, def), store, logger: ctx.logger })
|
|
152
163
|
loops.push(loop)
|
|
153
164
|
loop.start()
|
|
165
|
+
void renameTitle(def)
|
|
154
166
|
}
|
|
155
167
|
|
|
156
168
|
const sessionExists = async (sessionId) => {
|
|
@@ -281,13 +293,156 @@ export async function apply(ctx, rawConfig) {
|
|
|
281
293
|
}
|
|
282
294
|
process.once('exit', releaseAll)
|
|
283
295
|
|
|
296
|
+
const rebinding = new Set()
|
|
297
|
+
|
|
298
|
+
const lineTitle = (def, row) => String((row && row.title) || (def && def.title) || (def && def.role) || (def && def.label) || 'agent line').slice(0, 120)
|
|
299
|
+
|
|
300
|
+
const rebindLine = async ({ label, sessionId, actor, reason, migrate = 'queued', force = false, notify = true }) => {
|
|
301
|
+
if (label === config.createFrom) throw new LineError('LINE_MANAGER_REBIND_UNSUPPORTED', 'The manager line is rebound with line_claim_manager, not line_rebind', { line: label })
|
|
302
|
+
const row = store.getLineRow(config.agentId, label)
|
|
303
|
+
if (row === undefined) throw new LineError('LINE_NOT_FOUND', `No such line: ${config.agentId}#${label}`)
|
|
304
|
+
if (rebinding.has(label) || row.lifecycle === 'rebinding') throw new LineError('LINE_REBIND_BUSY', `Line ${label} is already rebinding`, { line: label })
|
|
305
|
+
if (!(await sessionExists(sessionId))) throw new LineError('LINE_SESSION_NOT_FOUND', `Session is not live or persisted: ${sessionId}`, { sessionId })
|
|
306
|
+
const target = lineDefs.get(sessionId)
|
|
307
|
+
if (target !== undefined && target.label !== label) throw new LineError('LINE_SESSION_BOUND', `Session ${sessionId} is already bound to ${target.label}`, { sessionId, line: target.label })
|
|
308
|
+
const oldDef = [...lineDefs.values()].find(line => line.label === label)
|
|
309
|
+
if (oldDef !== undefined && oldDef.sessionId === sessionId) {
|
|
310
|
+
return { line: label, oldSessionId: row.session_id, newSessionId: sessionId, generation: row.generation, counts: store.messageCounts({ agentId: config.agentId, line: label }), already: true }
|
|
311
|
+
}
|
|
312
|
+
const oldLifecycle = row.lifecycle || 'active'
|
|
313
|
+
const oldAgent = oldDef === undefined ? undefined : ctx.agents.get(oldDef.sessionId)
|
|
314
|
+
rebinding.add(label)
|
|
315
|
+
let locked = false
|
|
316
|
+
try {
|
|
317
|
+
store.setLifecycle({ agentId: config.agentId, line: label, lifecycle: 'rebinding', actor, reason })
|
|
318
|
+
locked = true
|
|
319
|
+
await stopLoopFor(label)
|
|
320
|
+
if (oldDef !== undefined) lineDefs.delete(oldDef.sessionId)
|
|
321
|
+
if (oldAgent !== undefined) unbindAgent(oldAgent)
|
|
322
|
+
const updated = store.rebind({
|
|
323
|
+
agentId: config.agentId, line: label, sessionId, actor, reason, migrate, locked: true,
|
|
324
|
+
})
|
|
325
|
+
const def = {
|
|
326
|
+
agentId: config.agentId, label, sessionId, lineId: lineId(config.agentId, label),
|
|
327
|
+
role: (oldDef && oldDef.role) || row.role || null,
|
|
328
|
+
title: lineTitle(oldDef, updated), createdBy: row.created_by || (oldDef && oldDef.createdBy) || null,
|
|
329
|
+
lifecycle: 'active', generation: updated.generation,
|
|
330
|
+
}
|
|
331
|
+
lineDefs.set(sessionId, def)
|
|
332
|
+
startLoop(def)
|
|
333
|
+
const agent = ctx.agents.get(sessionId)
|
|
334
|
+
if (agent !== undefined) bindAgent(agent)
|
|
335
|
+
if (notify !== false) {
|
|
336
|
+
try {
|
|
337
|
+
store.send({
|
|
338
|
+
agentId: config.agentId, fromLine: config.createFrom, to: label,
|
|
339
|
+
text: `[Agent Line] ${label} switched to a fresh Session. old=${row.session_id} new=${sessionId} migrated=${JSON.stringify(updated.counts)}. Read the handoff file before continuing.`,
|
|
340
|
+
noReply: true, maxReplyDepth: config.maxReplyDepth, maxMessageBytes: config.maxMessageBytes,
|
|
341
|
+
})
|
|
342
|
+
} catch (error) { ctx.logger?.warn?.('agent-lines: rebind notification failed: %s', String(error)) }
|
|
343
|
+
}
|
|
344
|
+
return { line: label, oldSessionId: row.session_id, newSessionId: sessionId, generation: updated.generation, counts: updated.counts }
|
|
345
|
+
} catch (error) {
|
|
346
|
+
try { await stopLoopFor(label) } catch { /* best effort */ }
|
|
347
|
+
if (oldDef !== undefined) {
|
|
348
|
+
lineDefs.set(oldDef.sessionId, oldDef)
|
|
349
|
+
startLoop(oldDef)
|
|
350
|
+
const agent = ctx.agents.get(oldDef.sessionId)
|
|
351
|
+
if (agent !== undefined) bindAgent(agent)
|
|
352
|
+
}
|
|
353
|
+
if (locked) {
|
|
354
|
+
try { store.setLifecycle({ agentId: config.agentId, line: label, lifecycle: oldLifecycle === 'rebinding' ? 'active' : oldLifecycle, actor, reason: 'rebind rollback' }) } catch { /* best effort */ }
|
|
355
|
+
}
|
|
356
|
+
throw error
|
|
357
|
+
} finally {
|
|
358
|
+
rebinding.delete(label)
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const retireLine = async ({ label, actor, reason }) => {
|
|
363
|
+
if (label === config.createFrom) throw new LineError('LINE_MANAGER_RETIRE_UNSUPPORTED', 'The manager line cannot be retired', { line: label })
|
|
364
|
+
const row = store.getLineRow(config.agentId, label)
|
|
365
|
+
if (row === undefined) throw new LineError('LINE_NOT_FOUND', `No such line: ${config.agentId}#${label}`)
|
|
366
|
+
const def = [...lineDefs.values()].find(line => line.label === label)
|
|
367
|
+
if (def !== undefined) def.lifecycle = 'retired'
|
|
368
|
+
await stopLoopFor(label)
|
|
369
|
+
const updated = store.retire({ agentId: config.agentId, line: label, actor, reason })
|
|
370
|
+
return { line: label, lifecycle: updated.lifecycle, counts: updated.counts, already: row.lifecycle === 'retired' }
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const renameLine = async ({ label, title, actor }) => {
|
|
374
|
+
const row = store.getLineRow(config.agentId, label)
|
|
375
|
+
if (row === undefined) throw new LineError('LINE_NOT_FOUND', `No such line: ${config.agentId}#${label}`)
|
|
376
|
+
const updated = store.setTitle({ agentId: config.agentId, line: label, title, actor })
|
|
377
|
+
const def = [...lineDefs.values()].find(line => line.label === label)
|
|
378
|
+
if (def !== undefined) def.title = updated.title
|
|
379
|
+
try { await ctx.sessionController?.rename?.({ sessionId: row.session_id, title: updated.title || title }) } catch { /* blank session can rename later */ }
|
|
380
|
+
return { line: label, title: updated.title }
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const replaceLine = async (existing, args, exec) => {
|
|
384
|
+
if (existing.label === config.createFrom) throw new LineError('LINE_MANAGER_REBIND_UNSUPPORTED', 'The manager line cannot be replaced', { line: existing.label })
|
|
385
|
+
if (existing.lifecycle === 'active' && args.force !== true) {
|
|
386
|
+
throw new LineError('LINE_REPLACE_ACTIVE', `Line ${existing.label} is active; pass force:true to replace it`, { line: existing.label, lifecycle: existing.lifecycle })
|
|
387
|
+
}
|
|
388
|
+
if (args.migrate === 'none') {
|
|
389
|
+
const backlog = store.messageCounts({ agentId: config.agentId, line: existing.label })
|
|
390
|
+
if (backlog.queued + backlog.claimed > 0) {
|
|
391
|
+
throw new LineError('LINE_BACKLOG_NOT_EMPTY', `Line ${existing.label} has ${backlog.queued + backlog.claimed} queued/claimed messages`, { line: existing.label, counts: backlog })
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
const baseCwd = exec?.agent?.session?.header?.cwd ?? process.cwd()
|
|
395
|
+
if (args.workspace !== undefined && args.cwd !== undefined) throw new LineError('LINE_INVALID_ARGUMENT', 'workspace and cwd are mutually exclusive')
|
|
396
|
+
const cwd = args.cwd === undefined || String(args.cwd).trim() === '' ? baseCwd : resolve(baseCwd, String(args.cwd).trim())
|
|
397
|
+
const targetWorkspace = args.workspace !== undefined ? await resolveWorkspace(args.workspace) : await managerWorkspace(baseCwd)
|
|
398
|
+
const role = args.role === undefined || args.role === null ? null : String(args.role).trim() || null
|
|
399
|
+
const title = args.title === undefined || args.title === null ? (role || existing.title || existing.role || existing.label) : String(args.title).trim().slice(0, 120)
|
|
400
|
+
const sessionId = `session-${randomUUID()}`
|
|
401
|
+
const created = await ctx.sessionController.create({
|
|
402
|
+
sessionId,
|
|
403
|
+
...(targetWorkspace === undefined ? { cwd } : { workspaceId: targetWorkspace.id }),
|
|
404
|
+
agentPreset: args.agentPreset === undefined ? config.childPreset : String(args.agentPreset),
|
|
405
|
+
})
|
|
406
|
+
const actualSessionId = created.sessionId
|
|
407
|
+
const result = await rebindLine({
|
|
408
|
+
label: existing.label, sessionId: actualSessionId, actor: (exec && exec.agent && exec.agent.session && exec.agent.session.id) || null,
|
|
409
|
+
reason: String(args.reason || 'line_create replace'), migrate: args.migrate === 'none' ? 'none' : 'queued', force: args.force === true, notify: false,
|
|
410
|
+
})
|
|
411
|
+
const newDef = lineDefs.get(actualSessionId)
|
|
412
|
+
if (newDef !== undefined) {
|
|
413
|
+
newDef.role = role
|
|
414
|
+
newDef.title = title
|
|
415
|
+
try { store.setTitle({ agentId: config.agentId, line: existing.label, title }) } catch { /* best effort */ }
|
|
416
|
+
}
|
|
417
|
+
let bootstrapQueued = false
|
|
418
|
+
if (args.bootstrap !== false && config.bootstrapOnCreate !== false) {
|
|
419
|
+
const creator = lineDefs.get(exec.agent.session.id)
|
|
420
|
+
if (creator !== undefined) {
|
|
421
|
+
try {
|
|
422
|
+
store.send({
|
|
423
|
+
agentId: config.agentId, fromLine: creator.label, to: existing.label,
|
|
424
|
+
text: `[Agent Line] ${existing.label} replaced. You are ${role ?? existing.role ?? existing.label}. Read the handoff file before starting; this is an identity message, do not modify files yet.`,
|
|
425
|
+
noReply: true, maxReplyDepth: config.maxReplyDepth, maxMessageBytes: config.maxMessageBytes,
|
|
426
|
+
})
|
|
427
|
+
bootstrapQueued = true
|
|
428
|
+
} catch (error) { ctx.logger?.warn?.('agent-lines: replace bootstrap failed: %s', String(error)) }
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
return {
|
|
432
|
+
line: existing.label, sessionId: actualSessionId, oldSessionId: result.oldSessionId,
|
|
433
|
+
role, title, created: true, replaced: true, generation: result.generation, counts: result.counts,
|
|
434
|
+
cwd: targetWorkspace?.path ?? cwd, workspaceId: targetWorkspace?.id ?? null, bootstrapQueued,
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
284
438
|
const createLine = async (args, exec) => {
|
|
285
439
|
if (closed) throw new LineError('LINE_CLOSED', 'The agent-lines plugin is shutting down')
|
|
286
440
|
if (!config.allowCreate) throw new LineError('LINE_CREATE_DISABLED', 'line_create is disabled by config')
|
|
287
441
|
const label = validateLineLabel(args.label)
|
|
288
442
|
const existing = store.getLineRow(config.agentId, label)
|
|
289
443
|
if (existing !== undefined) {
|
|
290
|
-
|
|
444
|
+
if (args.replace === true) return replaceLine(existing, args, exec)
|
|
445
|
+
return { line: existing.label, sessionId: existing.session_id, role: existing.role, title: existing.title ?? existing.role ?? existing.label, lifecycle: existing.lifecycle, created: false }
|
|
291
446
|
}
|
|
292
447
|
if (store.countLines(config.agentId) >= config.maxLines) {
|
|
293
448
|
throw new LineError('LINE_LIMIT', `Line limit reached: ${config.maxLines}`, { maxLines: config.maxLines })
|
|
@@ -303,6 +458,7 @@ export async function apply(ctx, rawConfig) {
|
|
|
303
458
|
? await resolveWorkspace(args.workspace)
|
|
304
459
|
: await managerWorkspace(baseCwd)
|
|
305
460
|
const role = args.role === undefined || args.role === null ? null : String(args.role).trim() || null
|
|
461
|
+
const title = args.title === undefined || args.title === null ? (role || label) : String(args.title).trim().slice(0, 120)
|
|
306
462
|
const sessionId = `session-${randomUUID()}`
|
|
307
463
|
const created = await ctx.sessionController.create({
|
|
308
464
|
sessionId,
|
|
@@ -311,7 +467,7 @@ export async function apply(ctx, rawConfig) {
|
|
|
311
467
|
})
|
|
312
468
|
const creator = lineDefs.get(exec.agent.session.id)
|
|
313
469
|
const actualSessionId = created.sessionId
|
|
314
|
-
const def = { agentId: config.agentId, label, sessionId: actualSessionId, lineId: lineId(config.agentId, label), role, createdBy: creator?.lineId ?? null }
|
|
470
|
+
const def = { agentId: config.agentId, label, sessionId: actualSessionId, lineId: lineId(config.agentId, label), role, title, createdBy: creator?.lineId ?? null, lifecycle: 'active', generation: 1 }
|
|
315
471
|
try {
|
|
316
472
|
store.acquire({
|
|
317
473
|
agentId: config.agentId,
|
|
@@ -329,13 +485,11 @@ export async function apply(ctx, rawConfig) {
|
|
|
329
485
|
ctx.logger?.warn?.('agent-lines: created Session %s but failed to register line %s', actualSessionId, label)
|
|
330
486
|
throw error
|
|
331
487
|
}
|
|
488
|
+
try { store.setTitle({ agentId: config.agentId, line: label, title }) } catch { /* title is best-effort after acquire */ }
|
|
332
489
|
lineDefs.set(actualSessionId, def)
|
|
333
490
|
startLoop(def)
|
|
334
491
|
const agent = ctx.agents.get(actualSessionId)
|
|
335
492
|
if (agent !== undefined) bindAgent(agent)
|
|
336
|
-
try {
|
|
337
|
-
await ctx.sessionController.rename({ sessionId: actualSessionId, title: `line: ${label}` })
|
|
338
|
-
} catch { /* a blank session may not materialize on rename; bootstrap/first delivery will */ }
|
|
339
493
|
let bootstrapQueued = false
|
|
340
494
|
if (args.bootstrap !== false && config.bootstrapOnCreate !== false && creator !== undefined) {
|
|
341
495
|
try {
|
|
@@ -357,6 +511,8 @@ export async function apply(ctx, rawConfig) {
|
|
|
357
511
|
line: label,
|
|
358
512
|
sessionId: actualSessionId,
|
|
359
513
|
role,
|
|
514
|
+
title,
|
|
515
|
+
lifecycle: 'active',
|
|
360
516
|
cwd: targetWorkspace?.path ?? cwd,
|
|
361
517
|
workspaceId: targetWorkspace?.id ?? null,
|
|
362
518
|
created: true,
|
|
@@ -384,6 +540,11 @@ export async function apply(ctx, rawConfig) {
|
|
|
384
540
|
ctx.logger?.info?.('agent-lines: manager %s is unbound; waiting for claim', def.label)
|
|
385
541
|
continue
|
|
386
542
|
}
|
|
543
|
+
const stored = store.getLineRow(config.agentId, def.label)
|
|
544
|
+
if (stored !== undefined && stored.lifecycle !== 'active') {
|
|
545
|
+
ctx.logger?.info?.('agent-lines: line %s is %s; skipping delivery loop', def.label, stored.lifecycle)
|
|
546
|
+
continue
|
|
547
|
+
}
|
|
387
548
|
if (!(await sessionExists(def.sessionId))) {
|
|
388
549
|
ctx.logger?.warn?.('agent-lines: Session %s for line %s is missing; line stays unbound until claimed', def.sessionId, def.label)
|
|
389
550
|
lineDefs.set(def.sessionId, { ...def, missing: true })
|
|
@@ -397,8 +558,9 @@ export async function apply(ctx, rawConfig) {
|
|
|
397
558
|
ttlMs: config.leaseTtlMs,
|
|
398
559
|
role: def.role,
|
|
399
560
|
})
|
|
400
|
-
|
|
401
|
-
|
|
561
|
+
const effective = { ...def, title: lineTitle(def, stored), lifecycle: 'active', generation: Number(stored?.generation ?? 1) }
|
|
562
|
+
lineDefs.set(def.sessionId, effective)
|
|
563
|
+
startLoop(effective)
|
|
402
564
|
}
|
|
403
565
|
cleanupFns.push(ctx.on('agent/created', ({ agent }) => { bindAgent(agent) }))
|
|
404
566
|
cleanupFns.push(ctx.on('agent/disposed', ({ agent }) => { unbindAgent(agent) }))
|
|
@@ -406,6 +568,7 @@ export async function apply(ctx, rawConfig) {
|
|
|
406
568
|
const managerLineId = lineId(config.agentId, config.createFrom)
|
|
407
569
|
for (const row of store.listLines()) {
|
|
408
570
|
if (row.created_by !== managerLineId || lineDefs.has(row.session_id)) continue
|
|
571
|
+
if (row.lifecycle !== 'active') continue
|
|
409
572
|
if (!(await sessionExists(row.session_id))) {
|
|
410
573
|
ctx.logger?.warn?.('agent-lines: dynamic line %s has no persisted Session; skipped restore', row.label)
|
|
411
574
|
continue
|
|
@@ -420,7 +583,7 @@ export async function apply(ctx, rawConfig) {
|
|
|
420
583
|
role: row.role,
|
|
421
584
|
createdBy: row.created_by,
|
|
422
585
|
})
|
|
423
|
-
const def = { agentId: config.agentId, label: row.label, sessionId: row.session_id, lineId: row.line_id, role: row.role, createdBy: row.created_by }
|
|
586
|
+
const def = { agentId: config.agentId, label: row.label, sessionId: row.session_id, lineId: row.line_id, role: row.role, title: lineTitle({ role: row.role, label: row.label }, row), createdBy: row.created_by, lifecycle: 'active', generation: Number(row.generation ?? 1) }
|
|
424
587
|
lineDefs.set(row.session_id, def)
|
|
425
588
|
startLoop(def)
|
|
426
589
|
} catch (error) {
|
|
@@ -469,4 +632,4 @@ export async function apply(ctx, rawConfig) {
|
|
|
469
632
|
store.close()
|
|
470
633
|
throw error
|
|
471
634
|
}
|
|
472
|
-
}
|
|
635
|
+
}
|
package/lib/prompt.js
CHANGED
|
File without changes
|
package/lib/protocol.js
CHANGED
|
File without changes
|
package/lib/store.js
CHANGED
|
@@ -33,10 +33,15 @@ CREATE TABLE IF NOT EXISTS lines (
|
|
|
33
33
|
agent_id TEXT NOT NULL,
|
|
34
34
|
label TEXT NOT NULL,
|
|
35
35
|
role TEXT,
|
|
36
|
+
title TEXT,
|
|
36
37
|
created_by TEXT,
|
|
37
38
|
session_id TEXT NOT NULL,
|
|
38
39
|
instance_id TEXT,
|
|
39
40
|
status TEXT NOT NULL DEFAULT 'offline',
|
|
41
|
+
lifecycle TEXT NOT NULL DEFAULT 'active',
|
|
42
|
+
generation INTEGER NOT NULL DEFAULT 1,
|
|
43
|
+
rebind_at INTEGER,
|
|
44
|
+
rebind_from TEXT,
|
|
40
45
|
last_seen INTEGER NOT NULL DEFAULT 0,
|
|
41
46
|
created_at INTEGER NOT NULL,
|
|
42
47
|
UNIQUE(agent_id, label)
|
|
@@ -97,7 +102,20 @@ CREATE TABLE IF NOT EXISTS manager_bindings (
|
|
|
97
102
|
updated_at INTEGER NOT NULL
|
|
98
103
|
);
|
|
99
104
|
|
|
100
|
-
|
|
105
|
+
CREATE TABLE IF NOT EXISTS line_events (
|
|
106
|
+
id TEXT PRIMARY KEY,
|
|
107
|
+
line_id TEXT NOT NULL,
|
|
108
|
+
action TEXT NOT NULL,
|
|
109
|
+
old_session_id TEXT,
|
|
110
|
+
new_session_id TEXT,
|
|
111
|
+
actor TEXT,
|
|
112
|
+
reason TEXT,
|
|
113
|
+
detail TEXT,
|
|
114
|
+
created_at INTEGER NOT NULL
|
|
115
|
+
);
|
|
116
|
+
CREATE INDEX IF NOT EXISTS line_events_line ON line_events(line_id, created_at);
|
|
117
|
+
|
|
118
|
+
PRAGMA user_version = 7;
|
|
101
119
|
`
|
|
102
120
|
|
|
103
121
|
export function defaultDbPath() {
|
|
@@ -140,12 +158,19 @@ export class LineStore {
|
|
|
140
158
|
this.db.exec(SCHEMA)
|
|
141
159
|
const columns = this.db.prepare('PRAGMA table_info(lines)').all().map(column => column.name)
|
|
142
160
|
if (!columns.includes('role')) this.db.exec('ALTER TABLE lines ADD COLUMN role TEXT')
|
|
161
|
+
if (!columns.includes('title')) this.db.exec('ALTER TABLE lines ADD COLUMN title TEXT')
|
|
143
162
|
if (!columns.includes('created_by')) this.db.exec('ALTER TABLE lines ADD COLUMN created_by TEXT')
|
|
163
|
+
if (!columns.includes('lifecycle')) this.db.exec("ALTER TABLE lines ADD COLUMN lifecycle TEXT NOT NULL DEFAULT 'active'")
|
|
164
|
+
if (!columns.includes('generation')) this.db.exec('ALTER TABLE lines ADD COLUMN generation INTEGER NOT NULL DEFAULT 1')
|
|
165
|
+
if (!columns.includes('rebind_at')) this.db.exec('ALTER TABLE lines ADD COLUMN rebind_at INTEGER')
|
|
166
|
+
if (!columns.includes('rebind_from')) this.db.exec('ALTER TABLE lines ADD COLUMN rebind_from TEXT')
|
|
144
167
|
const messageColumns = this.db.prepare('PRAGMA table_info(messages)').all().map(column => column.name)
|
|
145
168
|
if (!messageColumns.includes('root_message_id')) this.db.exec('ALTER TABLE messages ADD COLUMN root_message_id TEXT')
|
|
146
169
|
if (!messageColumns.includes('hop')) this.db.exec('ALTER TABLE messages ADD COLUMN hop INTEGER NOT NULL DEFAULT 0')
|
|
147
170
|
if (!messageColumns.includes('no_reply')) this.db.exec('ALTER TABLE messages ADD COLUMN no_reply INTEGER NOT NULL DEFAULT 0')
|
|
148
|
-
this.db.exec('
|
|
171
|
+
this.db.exec('CREATE TABLE IF NOT EXISTS line_events (id TEXT PRIMARY KEY, line_id TEXT NOT NULL, action TEXT NOT NULL, old_session_id TEXT, new_session_id TEXT, actor TEXT, reason TEXT, detail TEXT, created_at INTEGER NOT NULL)')
|
|
172
|
+
this.db.exec('CREATE INDEX IF NOT EXISTS line_events_line ON line_events(line_id, created_at)')
|
|
173
|
+
this.db.exec('PRAGMA user_version = 7')
|
|
149
174
|
this.prepared = new Map()
|
|
150
175
|
this.closed = false
|
|
151
176
|
this.fresh = fresh
|
|
@@ -244,6 +269,8 @@ export class LineStore {
|
|
|
244
269
|
const from = this.#stmt('SELECT * FROM lines WHERE agent_id = ? AND label = ?').get(agentId, fromLine)
|
|
245
270
|
if (from === undefined) throw new LineError('LINE_NOT_FOUND', `Sender line is not registered: ${agentId}#${fromLine}`)
|
|
246
271
|
const target = this.resolveTarget(agentId, to)
|
|
272
|
+
if (target.row.lifecycle === 'retired') throw new LineError('LINE_RETIRED', `Line ${target.lineId} is retired`, { line: target.lineId })
|
|
273
|
+
if (target.row.lifecycle === 'rebinding') throw new LineError('LINE_REBINDING', `Line ${target.lineId} is rebinding`, { line: target.lineId })
|
|
247
274
|
const body = assertMessageSize(text, maxMessageBytes)
|
|
248
275
|
let parent
|
|
249
276
|
if (replyTo !== undefined && replyTo !== null && String(replyTo).trim() !== '') {
|
|
@@ -383,13 +410,19 @@ export class LineStore {
|
|
|
383
410
|
/** All lines with unread counts and live lease state. */
|
|
384
411
|
listLines(now = Date.now()) {
|
|
385
412
|
const rows = this.#stmt(`SELECT l.*, (SELECT COUNT(*) FROM messages m
|
|
386
|
-
WHERE m.to_agent = l.agent_id AND m.to_line = l.label AND m.state IN ('queued', 'claimed', 'delivered')) AS unread
|
|
413
|
+
WHERE m.to_agent = l.agent_id AND m.to_line = l.label AND m.state IN ('queued', 'claimed', 'delivered')) AS unread,
|
|
414
|
+
(SELECT COUNT(*) FROM messages m
|
|
415
|
+
WHERE m.to_agent = l.agent_id AND m.to_line = l.label AND m.state IN ('queued', 'claimed')) AS pending,
|
|
416
|
+
(SELECT MAX(m.created_at) FROM messages m
|
|
417
|
+
WHERE m.to_agent = l.agent_id AND m.to_line = l.label) AS last_activity_at
|
|
387
418
|
FROM lines l ORDER BY l.agent_id, l.label`).all()
|
|
388
419
|
return rows.map(row => {
|
|
389
420
|
const lease = this.#stmt('SELECT * FROM leases WHERE line_id = ?').get(row.line_id)
|
|
390
421
|
return {
|
|
391
422
|
...row,
|
|
392
423
|
unread: Number(row.unread),
|
|
424
|
+
pending: Number(row.pending ?? 0),
|
|
425
|
+
lastActivityAt: row.last_activity_at === null ? null : Number(row.last_activity_at),
|
|
393
426
|
lease: lease === undefined ? null : {
|
|
394
427
|
instanceId: lease.instance_id,
|
|
395
428
|
expiresAt: lease.expires_at,
|
|
@@ -406,13 +439,138 @@ export class LineStore {
|
|
|
406
439
|
if (row === undefined) return undefined
|
|
407
440
|
const lease = this.#stmt('SELECT * FROM leases WHERE line_id = ?').get(id)
|
|
408
441
|
const unread = this.#stmt(`SELECT COUNT(*) AS n FROM messages WHERE to_agent = ? AND to_line = ? AND state IN ('queued', 'claimed', 'delivered')`).get(agentId, line)
|
|
442
|
+
const pending = this.#stmt(`SELECT COUNT(*) AS n FROM messages WHERE to_agent = ? AND to_line = ? AND state IN ('queued', 'claimed')`).get(agentId, line)
|
|
443
|
+
const activity = this.#stmt('SELECT MAX(created_at) AS n FROM messages WHERE to_agent = ? AND to_line = ?').get(agentId, line)
|
|
409
444
|
return {
|
|
410
445
|
...row,
|
|
411
446
|
unread: Number(unread.n),
|
|
447
|
+
pending: Number(pending.n),
|
|
448
|
+
lastActivityAt: activity.n === null ? null : Number(activity.n),
|
|
412
449
|
lease: lease === undefined ? null : { instanceId: lease.instance_id, expiresAt: lease.expires_at, live: lease.expires_at > now },
|
|
413
450
|
}
|
|
414
451
|
}
|
|
415
452
|
|
|
453
|
+
/** Update the display title of a line. Persisted so restarts keep it. */
|
|
454
|
+
setTitle({ agentId, line, title, actor = null, now = Date.now() }) {
|
|
455
|
+
const id = lineId(agentId, line)
|
|
456
|
+
return this.#tx(() => {
|
|
457
|
+
const row = this.#stmt('SELECT * FROM lines WHERE line_id = ?').get(id)
|
|
458
|
+
if (row === undefined) throw new LineError('LINE_NOT_FOUND', `No such line: ${id}`, { line: id })
|
|
459
|
+
const value = title === null || title === undefined ? null : String(title).trim().slice(0, 120) || null
|
|
460
|
+
this.#stmt('UPDATE lines SET title = ? WHERE line_id = ?').run(value, id)
|
|
461
|
+
this.#event({ lineId: id, action: 'title', actor, detail: { title: value }, now })
|
|
462
|
+
return { ...this.#stmt('SELECT * FROM lines WHERE line_id = ?').get(id) }
|
|
463
|
+
})
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/** Set lifecycle without a Session rebind (used to lock a rebind in progress). */
|
|
467
|
+
setLifecycle({ agentId, line, lifecycle, actor = null, reason = null, now = Date.now() }) {
|
|
468
|
+
const id = lineId(agentId, line)
|
|
469
|
+
if (!['active', 'retired', 'rebinding'].includes(String(lifecycle))) {
|
|
470
|
+
throw new LineError('LINE_INVALID_LIFECYCLE', `Invalid lifecycle: ${lifecycle}`)
|
|
471
|
+
}
|
|
472
|
+
return this.#tx(() => {
|
|
473
|
+
const row = this.#stmt('SELECT * FROM lines WHERE line_id = ?').get(id)
|
|
474
|
+
if (row === undefined) throw new LineError('LINE_NOT_FOUND', `No such line: ${id}`, { line: id })
|
|
475
|
+
this.#stmt("UPDATE lines SET lifecycle = ?, status = CASE WHEN ? = 'active' THEN status ELSE 'offline' END WHERE line_id = ?")
|
|
476
|
+
.run(String(lifecycle), String(lifecycle), id)
|
|
477
|
+
this.#event({ lineId: id, action: lifecycle, actor, reason, now })
|
|
478
|
+
return { ...this.#stmt('SELECT * FROM lines WHERE line_id = ?').get(id) }
|
|
479
|
+
})
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/** Queue/claimed/delivered counts for one line. */
|
|
483
|
+
messageCounts({ agentId, line }) {
|
|
484
|
+
const rows = this.#stmt(`SELECT state, COUNT(*) AS n FROM messages
|
|
485
|
+
WHERE to_agent = ? AND to_line = ? GROUP BY state`).all(agentId, line)
|
|
486
|
+
const out = { queued: 0, claimed: 0, delivered: 0, acked: 0, failed: 0, expired: 0, dead: 0 }
|
|
487
|
+
for (const row of rows) out[row.state] = Number(row.n)
|
|
488
|
+
return out
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** Retire a line: no new sends, keep Session/queue/history. */
|
|
492
|
+
retire({ agentId, line, actor = null, reason = null, now = Date.now() }) {
|
|
493
|
+
const id = lineId(agentId, line)
|
|
494
|
+
return this.#tx(() => {
|
|
495
|
+
const row = this.#stmt('SELECT * FROM lines WHERE line_id = ?').get(id)
|
|
496
|
+
if (row === undefined) throw new LineError('LINE_NOT_FOUND', `No such line: ${id}`, { line: id })
|
|
497
|
+
if (row.lifecycle !== 'retired') {
|
|
498
|
+
this.#stmt("UPDATE lines SET lifecycle = 'retired', status = 'offline' WHERE line_id = ?").run(id)
|
|
499
|
+
this.#event({ lineId: id, action: 'retire', actor, reason, detail: this.messageCounts({ agentId, line }), now })
|
|
500
|
+
}
|
|
501
|
+
return { ...this.#stmt('SELECT * FROM lines WHERE line_id = ?').get(id), counts: this.messageCounts({ agentId, line }) }
|
|
502
|
+
})
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Atomically switch a line to a new Session.
|
|
507
|
+
* migrate=queued: requeue claimed messages; queued/claimed will be delivered by the new loop.
|
|
508
|
+
* migrate=none: fail-closed if queued+claimed > 0 (manager must drain first).
|
|
509
|
+
* delivered/acked/failed/expired are never re-delivered.
|
|
510
|
+
*/
|
|
511
|
+
rebind({ agentId, line, sessionId, actor = null, reason = null, migrate = 'queued', locked = false, now = Date.now() }) {
|
|
512
|
+
const id = lineId(agentId, line)
|
|
513
|
+
if (!['queued', 'none'].includes(String(migrate))) {
|
|
514
|
+
throw new LineError('LINE_INVALID_ARGUMENT', `migrate must be queued or none: ${migrate}`)
|
|
515
|
+
}
|
|
516
|
+
return this.#tx(() => {
|
|
517
|
+
const row = this.#stmt('SELECT * FROM lines WHERE line_id = ?').get(id)
|
|
518
|
+
if (row === undefined) throw new LineError('LINE_NOT_FOUND', `No such line: ${id}`, { line: id })
|
|
519
|
+
if (row.lifecycle === 'rebinding' && locked !== true) throw new LineError('LINE_REBIND_BUSY', `Line ${id} is already rebinding`, { line: id })
|
|
520
|
+
const counts = this.messageCounts({ agentId, line })
|
|
521
|
+
if (String(migrate) === 'none' && counts.queued + counts.claimed > 0) {
|
|
522
|
+
throw new LineError('LINE_BACKLOG_NOT_EMPTY', `Line ${id} has ${counts.queued + counts.claimed} queued/claimed messages; drain or use migrate=queued`, { line: id, counts })
|
|
523
|
+
}
|
|
524
|
+
if (String(migrate) === 'queued') {
|
|
525
|
+
this.#stmt(`UPDATE messages SET state = 'queued', claimed_by = NULL, claimed_at = NULL, next_attempt_at = ?
|
|
526
|
+
WHERE to_agent = ? AND to_line = ? AND state = 'claimed'`).run(now, agentId, line)
|
|
527
|
+
}
|
|
528
|
+
this.#stmt('DELETE FROM leases WHERE line_id = ?').run(id)
|
|
529
|
+
const generation = Number(row.generation ?? 1) + 1
|
|
530
|
+
this.#stmt(`UPDATE lines SET session_id = ?, lifecycle = 'active', generation = ?, rebind_at = ?, rebind_from = ?,
|
|
531
|
+
instance_id = NULL, status = 'offline', last_seen = ? WHERE line_id = ?`)
|
|
532
|
+
.run(String(sessionId), generation, now, row.session_id, now, id)
|
|
533
|
+
const detail = { migrate: String(migrate), before: counts, after: this.messageCounts({ agentId, line }), generation }
|
|
534
|
+
this.#event({ lineId: id, action: 'rebind', oldSessionId: row.session_id, newSessionId: String(sessionId), actor, reason, detail, now })
|
|
535
|
+
return { ...this.#stmt('SELECT * FROM lines WHERE line_id = ?').get(id), oldSessionId: row.session_id, counts: detail.after }
|
|
536
|
+
})
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/** Last rebind event for a line (used to show oldSessionId without overwriting the row). */
|
|
540
|
+
lastLineEvent({ agentId, line, action = 'rebind' }) {
|
|
541
|
+
const id = lineId(agentId, line)
|
|
542
|
+
return this.#stmt('SELECT * FROM line_events WHERE line_id = ? AND action = ? ORDER BY created_at DESC LIMIT 1').get(id, action)
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/** Structured handoff snapshot: counts + recent messages + lifecycle events. */
|
|
546
|
+
handoff({ agentId, line, limit = 20, now = Date.now() }) {
|
|
547
|
+
const row = this.getLine(agentId, line, now)
|
|
548
|
+
if (row === undefined) throw new LineError('LINE_NOT_FOUND', `No such line: ${lineId(agentId, line)}`)
|
|
549
|
+
const recent = this.#stmt(`SELECT * FROM messages WHERE to_agent = ? AND to_line = ? ORDER BY created_at DESC LIMIT ?`).all(agentId, line, limit).reverse()
|
|
550
|
+
const events = this.lineEvents({ agentId, line, limit: 10 })
|
|
551
|
+
const lastEvent = events.length ? events[0] : null
|
|
552
|
+
return {
|
|
553
|
+
line: line, label: line, role: row.role, title: row.title ?? row.role ?? row.label,
|
|
554
|
+
sessionId: row.session_id, lifecycle: row.lifecycle ?? 'active', generation: row.generation ?? 1,
|
|
555
|
+
createdBy: row.created_by, createdAt: row.created_at, counts: this.messageCounts({ agentId, line }),
|
|
556
|
+
lastActivityAt: row.lastActivityAt, oldSessionId: lastEvent && lastEvent.action === 'rebind' ? lastEvent.old_session_id : null,
|
|
557
|
+
lastEvent, events, messages: recent,
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/** Recent lifecycle events for diagnostics. */
|
|
562
|
+
lineEvents({ agentId, line, limit = 20 }) {
|
|
563
|
+
const id = lineId(agentId, line)
|
|
564
|
+
return this.#stmt('SELECT * FROM line_events WHERE line_id = ? ORDER BY created_at DESC LIMIT ?').all(id, limit)
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
#event({ lineId: target, action, oldSessionId = null, newSessionId = null, actor = null, reason = null, detail = null, now = Date.now() }) {
|
|
568
|
+
const id = 'lineev_' + randomUUID().replaceAll('-', '')
|
|
569
|
+
this.#stmt(`INSERT INTO line_events(id, line_id, action, old_session_id, new_session_id, actor, reason, detail, created_at)
|
|
570
|
+
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, target, action, oldSessionId, newSessionId, actor, reason, detail === null ? null : JSON.stringify(detail), now)
|
|
571
|
+
return id
|
|
572
|
+
}
|
|
573
|
+
|
|
416
574
|
counts(now = Date.now()) {
|
|
417
575
|
const states = this.#stmt('SELECT state, COUNT(*) AS n FROM messages GROUP BY state ORDER BY state').all()
|
|
418
576
|
const leases = this.#stmt('SELECT COUNT(*) AS n FROM leases WHERE expires_at > ?').get(now)
|
|
@@ -421,4 +579,4 @@ export class LineStore {
|
|
|
421
579
|
}
|
|
422
580
|
}
|
|
423
581
|
|
|
424
|
-
export { expandHome }
|
|
582
|
+
export { expandHome }
|
package/lib/tools.js
CHANGED
|
@@ -51,6 +51,7 @@ function materializationOf(ctx, sessionId) {
|
|
|
51
51
|
function currentView(def) {
|
|
52
52
|
return def === undefined ? null : {
|
|
53
53
|
agentId: def.agentId, line: def.label, sessionId: def.sessionId, lineId: def.lineId, role: def.role,
|
|
54
|
+
title: def.title ?? def.role ?? def.label, lifecycle: def.lifecycle ?? 'active', generation: def.generation ?? 1,
|
|
54
55
|
}
|
|
55
56
|
}
|
|
56
57
|
|
|
@@ -94,6 +95,12 @@ export function registerStatusTool(ctx, { config, store, lineDefs }) {
|
|
|
94
95
|
const lines = store.listLines().map(line => ({
|
|
95
96
|
label: line.label,
|
|
96
97
|
role: line.role,
|
|
98
|
+
title: line.title ?? line.role ?? line.label,
|
|
99
|
+
lifecycle: line.lifecycle ?? 'active',
|
|
100
|
+
generation: line.generation ?? 1,
|
|
101
|
+
oldSessionId: store.lastLineEvent({ agentId: line.agent_id, line: line.label, action: 'rebind' })?.old_session_id ?? null,
|
|
102
|
+
pending: line.pending ?? 0,
|
|
103
|
+
lastActivityAt: line.lastActivityAt ?? null,
|
|
97
104
|
sessionId: line.session_id,
|
|
98
105
|
createdBy: line.created_by,
|
|
99
106
|
lease: line.lease,
|
|
@@ -107,6 +114,8 @@ export function registerStatusTool(ctx, { config, store, lineDefs }) {
|
|
|
107
114
|
bound: currentDef !== undefined,
|
|
108
115
|
line: currentDef?.label ?? null,
|
|
109
116
|
role: currentDef?.role ?? null,
|
|
117
|
+
title: currentDef?.title ?? currentDef?.role ?? null,
|
|
118
|
+
lifecycle: currentDef?.lifecycle ?? null,
|
|
110
119
|
}
|
|
111
120
|
const repair = current === null
|
|
112
121
|
? 'No calling Session; line_status is informational.'
|
|
@@ -157,7 +166,7 @@ export function registerClaimTool(ctx, { config, lineDefs, rebindManager }) {
|
|
|
157
166
|
}
|
|
158
167
|
|
|
159
168
|
/** Register the model tools; returns their disposers. */
|
|
160
|
-
export function registerToolsForAgent(agent, def, { config, store, lineDefs, createLine, moveLineWorkspace }) {
|
|
169
|
+
export function registerToolsForAgent(agent, def, { config, store, lineDefs, createLine, moveLineWorkspace, retireLine, rebindLine, renameLine }) {
|
|
161
170
|
const disposers = []
|
|
162
171
|
|
|
163
172
|
disposers.push(agent.ctx.tools.register({
|
|
@@ -195,6 +204,7 @@ export function registerToolsForAgent(agent, def, { config, store, lineDefs, cre
|
|
|
195
204
|
output: OUTPUT,
|
|
196
205
|
execute: async (args, exec) => {
|
|
197
206
|
const def = currentLine(exec, lineDefs)
|
|
207
|
+
if (def.lifecycle === 'retired') throw new LineError('LINE_RETIRED', `Line ${def.label} is retired; it cannot send new messages`, { line: def.label })
|
|
198
208
|
const text = assertMessageSize(args.text, config.maxMessageBytes)
|
|
199
209
|
const target = parseTarget(args.to, def.agentId)
|
|
200
210
|
if (target.lineId === def.lineId && config.allowSelfSend !== true) {
|
|
@@ -291,6 +301,11 @@ export function registerToolsForAgent(agent, def, { config, store, lineDefs, cre
|
|
|
291
301
|
properties: {
|
|
292
302
|
label: { type: 'string', description: 'Stable line label, e.g. "frontend-line"' },
|
|
293
303
|
role: { type: 'string', description: 'Short role or responsibility description for the new line' },
|
|
304
|
+
title: { type: 'string', description: 'Sidebar title; defaults to role or label' },
|
|
305
|
+
replace: { type: 'boolean', description: 'Replace an existing line with a fresh Session; old Session is retired (manager only)' },
|
|
306
|
+
force: { type: 'boolean', description: 'Allow replacing an active line; use with reason' },
|
|
307
|
+
migrate: { type: 'string', enum: ['queued', 'none'], description: 'Backlog policy on replace; none fails if queued/claimed exists' },
|
|
308
|
+
reason: { type: 'string', description: 'Reason for replace; shown in line_events' },
|
|
294
309
|
agentPreset: { type: 'string', description: 'DSH agent preset; defaults to the plugin childPreset' },
|
|
295
310
|
workspace: { type: 'string', description: 'Target workspace path or id; defaults to the manager workspace. Existing workspace only.' },
|
|
296
311
|
cwd: { type: 'string', description: 'Working directory when no workspace is named; defaults to the manager Session cwd' },
|
|
@@ -310,6 +325,117 @@ export function registerToolsForAgent(agent, def, { config, store, lineDefs, cre
|
|
|
310
325
|
},
|
|
311
326
|
}))
|
|
312
327
|
|
|
328
|
+
disposers.push(agent.ctx.tools.register({
|
|
329
|
+
name: 'line_retire',
|
|
330
|
+
description: 'Retire an existing line: new line_send is rejected, the Session and message history are kept, and the delivery loop stops. Only the configured manager line may call this. Use line_create replace:true to hand the same role to a fresh Session.',
|
|
331
|
+
parameters: {
|
|
332
|
+
type: 'object', additionalProperties: false,
|
|
333
|
+
properties: {
|
|
334
|
+
line: { type: 'string', description: 'Existing line label' },
|
|
335
|
+
reason: { type: 'string', description: 'Why this line is being retired' },
|
|
336
|
+
},
|
|
337
|
+
required: ['line'],
|
|
338
|
+
},
|
|
339
|
+
output: OUTPUT,
|
|
340
|
+
execute: async (args, exec) => {
|
|
341
|
+
const caller = currentLine(exec, lineDefs)
|
|
342
|
+
if (caller.label !== config.createFrom) throw new LineError('LINE_CREATE_FORBIDDEN', `Only ${config.createFrom} may retire lines`, { caller: caller.label })
|
|
343
|
+
return retireLine({ label: String(args.line), actor: caller.label, reason: args.reason ?? null })
|
|
344
|
+
},
|
|
345
|
+
}))
|
|
346
|
+
|
|
347
|
+
disposers.push(agent.ctx.tools.register({
|
|
348
|
+
name: 'line_rebind',
|
|
349
|
+
description: 'Atomically switch an existing line to a new live Session, migrating queued/claimed messages. The old Session is kept, never deleted. Only the configured manager line may call this; use line_create replace:true to create a fresh Session in one step.',
|
|
350
|
+
parameters: {
|
|
351
|
+
type: 'object', additionalProperties: false,
|
|
352
|
+
properties: {
|
|
353
|
+
line: { type: 'string', description: 'Existing line label' },
|
|
354
|
+
sessionId: { type: 'string', description: 'Live root Session id to bind' },
|
|
355
|
+
migrate: { type: 'string', enum: ['queued', 'none'], description: 'queued migrates queued/claimed; none fails if backlog is non-empty' },
|
|
356
|
+
reason: { type: 'string', description: 'Why this line is being rebound' },
|
|
357
|
+
force: { type: 'boolean', description: 'Accepted for API compatibility; active replacement is gated by line_create replace' },
|
|
358
|
+
},
|
|
359
|
+
required: ['line', 'sessionId'],
|
|
360
|
+
},
|
|
361
|
+
output: OUTPUT,
|
|
362
|
+
execute: async (args, exec) => {
|
|
363
|
+
const caller = currentLine(exec, lineDefs)
|
|
364
|
+
if (caller.label !== config.createFrom) throw new LineError('LINE_CREATE_FORBIDDEN', `Only ${config.createFrom} may rebind lines`, { caller: caller.label })
|
|
365
|
+
return rebindLine({ label: String(args.line), sessionId: String(args.sessionId), actor: caller.label, reason: args.reason ?? null, migrate: args.migrate === 'none' ? 'none' : 'queued', force: args.force === true })
|
|
366
|
+
},
|
|
367
|
+
}))
|
|
368
|
+
|
|
369
|
+
disposers.push(agent.ctx.tools.register({
|
|
370
|
+
name: 'line_rename',
|
|
371
|
+
description: 'Rename a line display title in the sidebar. The title is persisted in lines.db and survives restarts. Only the configured manager line may call this.',
|
|
372
|
+
parameters: {
|
|
373
|
+
type: 'object', additionalProperties: false,
|
|
374
|
+
properties: {
|
|
375
|
+
line: { type: 'string', description: 'Existing line label' },
|
|
376
|
+
title: { type: 'string', description: 'New title, 1-120 chars' },
|
|
377
|
+
},
|
|
378
|
+
required: ['line', 'title'],
|
|
379
|
+
},
|
|
380
|
+
output: OUTPUT,
|
|
381
|
+
execute: async (args, exec) => {
|
|
382
|
+
const caller = currentLine(exec, lineDefs)
|
|
383
|
+
if (caller.label !== config.createFrom) throw new LineError('LINE_CREATE_FORBIDDEN', `Only ${config.createFrom} may rename lines`, { caller: caller.label })
|
|
384
|
+
if (String(args.title ?? '').trim() === '') throw new LineError('LINE_INVALID_ARGUMENT', 'title must not be empty')
|
|
385
|
+
return renameLine({ label: String(args.line), title: String(args.title), actor: caller.label })
|
|
386
|
+
},
|
|
387
|
+
}))
|
|
388
|
+
|
|
389
|
+
disposers.push(agent.ctx.tools.register({
|
|
390
|
+
name: 'line_handoff',
|
|
391
|
+
description: 'Generate a structured handoff snapshot for a line: current metadata, backlog counts, recent messages, lifecycle events. Does not call a model; write the returned markdown to a file before retire/replace/rebind.',
|
|
392
|
+
parameters: {
|
|
393
|
+
type: 'object', additionalProperties: false,
|
|
394
|
+
properties: {
|
|
395
|
+
line: { type: 'string', description: 'Existing line label' },
|
|
396
|
+
format: { type: 'string', enum: ['md', 'json'], description: 'md (default) returns markdown; json returns the snapshot object' },
|
|
397
|
+
limit: { type: 'number', description: 'Recent messages to include, 1-100 (default 20)' },
|
|
398
|
+
},
|
|
399
|
+
required: ['line'],
|
|
400
|
+
},
|
|
401
|
+
output: OUTPUT,
|
|
402
|
+
execute: async (args, exec) => {
|
|
403
|
+
const caller = currentLine(exec, lineDefs)
|
|
404
|
+
if (caller.label !== config.createFrom) throw new LineError('LINE_CREATE_FORBIDDEN', `Only ${config.createFrom} may create handoffs`, { caller: caller.label })
|
|
405
|
+
const limit = limitValue(args.limit, 20, 100)
|
|
406
|
+
const data = store.handoff({ agentId: config.agentId, line: String(args.line), limit })
|
|
407
|
+
if (args.format === 'json') return data
|
|
408
|
+
const lines = [
|
|
409
|
+
'# 交接单 · ' + data.label,
|
|
410
|
+
'',
|
|
411
|
+
'## 1 我是谁',
|
|
412
|
+
'- 角色: ' + (data.role ?? data.label),
|
|
413
|
+
'- 当前 label: ' + data.label,
|
|
414
|
+
'- Session: ' + data.sessionId,
|
|
415
|
+
'- lifecycle: ' + data.lifecycle + ' (generation ' + data.generation + ')',
|
|
416
|
+
'',
|
|
417
|
+
'## 2 积压',
|
|
418
|
+
'- queued: ' + data.counts.queued,
|
|
419
|
+
'- claimed: ' + data.counts.claimed,
|
|
420
|
+
'- delivered: ' + data.counts.delivered,
|
|
421
|
+
'- acked: ' + data.counts.acked,
|
|
422
|
+
'- failed/expired: ' + (data.counts.failed + data.counts.expired),
|
|
423
|
+
'',
|
|
424
|
+
'## 3 最近消息',
|
|
425
|
+
...(data.messages.length === 0 ? ['- 无'] : data.messages.map(m => `- ${new Date(m.created_at).toISOString()} [${m.state}] ${m.from_line} -> ${m.to_line}: ${String(m.body).replace(/\s+/g, ' ').slice(0, 180)}`)),
|
|
426
|
+
'',
|
|
427
|
+
'## 4 生命周期',
|
|
428
|
+
...(data.events.length === 0 ? ['- 无'] : data.events.map(e => `- ${new Date(e.created_at).toISOString()} ${e.action} ${e.old_session_id ? e.old_session_id + ' -> ' : ''}${e.new_session_id ?? ''}`)),
|
|
429
|
+
'',
|
|
430
|
+
'## 5 交接提示',
|
|
431
|
+
'- 先读本文件,再继续工作。',
|
|
432
|
+
'- 不要复用旧 Session;它只保留历史。',
|
|
433
|
+
'- 如有 queued/claimed,新线会继续收到。',
|
|
434
|
+
]
|
|
435
|
+
return { line: data.label, format: 'md', markdown: lines.join('\n'), counts: data.counts, sessionId: data.sessionId, generation: data.generation }
|
|
436
|
+
},
|
|
437
|
+
}))
|
|
438
|
+
|
|
313
439
|
disposers.push(agent.ctx.tools.register({
|
|
314
440
|
name: 'line_move_workspace',
|
|
315
441
|
description: 'Move an existing line Session into a target workspace record so it appears under that workspace in the sidebar. DSH only allows attachment when the Session cwd exactly matches the workspace path; it cannot move a Session to a different directory (use a new line for that).',
|
|
@@ -334,4 +460,4 @@ export function registerToolsForAgent(agent, def, { config, store, lineDefs, cre
|
|
|
334
460
|
}
|
|
335
461
|
|
|
336
462
|
return disposers
|
|
337
|
-
}
|
|
463
|
+
}
|
package/lib/transport.js
CHANGED
|
File without changes
|