@rezti/dsh-rez-task-handoff 0.0.0-stage → 0.1.0-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +13 -0
- package/README.md +62 -2
- package/inspect.mjs +44 -0
- package/module/index.mjs +454 -0
- package/module/ledger.mjs +66 -0
- package/package.json +25 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Copyright 2026 ReZ-TI / dsh-rez-suite contributors
|
|
2
|
+
|
|
3
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
you may not use this file except in compliance with the License.
|
|
5
|
+
You may obtain a copy of the License at
|
|
6
|
+
|
|
7
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
|
|
9
|
+
Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
See the License for the specific language governing permissions and
|
|
13
|
+
limitations under the License.
|
package/README.md
CHANGED
|
@@ -1,3 +1,63 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Jarvis 跨会话任务交接(模拟版 0.1.0-beta.2)
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
适用目标:DeepSeek Harness 桌面版,宿主 0.2.1-rc.5,Aurabot 1.1.24,desktop profile。只接受成功、失败、超时、需要确认四种模拟任务。没有 HA、摄像头、真实设备、系统命令或任意任务执行入口。不依赖或改动 RezSuite。
|
|
4
|
+
|
|
5
|
+
## 从 npm 下载并检查
|
|
6
|
+
|
|
7
|
+
这是通过 npm 分发的 **Aurabot 用户模块**。以下命令下载已发布的 beta.1。测试 beta.2 本地包时,把对应的压缩包解压到临时目录即可。
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
cd "$(mktemp -d)"
|
|
11
|
+
npm pack @rezti/dsh-rez-task-handoff@0.1.0-beta.1 --ignore-scripts --registry https://registry.npmjs.org
|
|
12
|
+
tar -xzf rezti-dsh-rez-task-handoff-0.1.0-beta.1.tgz
|
|
13
|
+
node package/inspect.mjs
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
使用非默认目录时,可以向检查脚本依次传入 DSH 数据目录、应用路径。脚本只输出版本和存在性信息;应用安装版本、进程存在、profile 安装状态和实际激活是不同事实。再从桌面版插件面板核对实际启用状态,从 Jarvis 专用会话复制完整 ID。若还没有会话,请创建一个专用于模拟测试的会话。扩展使用固定 ID,不按名称搜索。
|
|
17
|
+
|
|
18
|
+
## 接口与取舍
|
|
19
|
+
|
|
20
|
+
- [Aurabot 1.1.24 用户模块 API](https://github.com/aureth-labs/dsh-aurabot-qq/blob/v1.1.24/src/modules/API.md):使用 API v1 的 `tool`、`defineTool`、`prompt`、`rule`、`settings`、`isOwner`、`dataDir`、`every` 和卸载回调。用户模块目录与插件安装目录分离,升级保留;本扩展检测到其他 Aurabot 版本会停用,待核对再放开。
|
|
21
|
+
- 从 1.1.24 源码验证,Aurabot 在 QQ agent 的 `agent.ctx.tools` 上挂模块工具。“纯聊天”档不挂模块工具,需要已有的支持工具档位。本模块同时核对配置的主人、原私聊 ID、实际执行会话和最新入站消息的可信标记。主人私聊的普通消息可查状态,提交须明确发送「让 Jarvis 做交接测试 success」或指定其他模拟模式。群聊、其他私聊、后台轮、DSH 直接输入、回传轮都不能触发。
|
|
22
|
+
- DSH 0.2.1-rc.5 发行包已验证 `agents.get`、`Agent.followup`、`session/event`、`ToolRuntime.restrict/guard` 与 `exec.concludeTurn()`。用固定的完整会话 ID 定位已打开的 Jarvis。用唯一输入消息 ID 关联 `user/message` 与对应 `turn/end`,且只有模拟工具的结果可成为任务结果。`whenIdle()` 只用于清理;它不是具体任务的完成回执。测试运行时实际装载了同版本 AgentLoop。
|
|
23
|
+
- 回传使用原 QQ Agent 的原生 follow-up。Aurabot 的聊天中枢会监听原会话事件并发送普通回复。以 `hub.sessions()`、`chatOfSession()` 做最后校验,映射变更、Agent 不在、主人静音时先等;通知最多等待十分钟。本机发布代码与真实核心的隔离测试证明这条路径成立。Aurabot 的 hub 映射和自动转发是版本相关的实现,未宣称跨版本稳定。没有采用 `api.think`:它会走后台会话。
|
|
24
|
+
- [dsh-peer-bus](https://github.com/ybh1291747665/dsh-peer-bus) 和 [dsh-cross-session-agent](https://github.com/dd2673/dsh-cross-session-agent) 有成熟的会话间寻址、投递思路,但不能直接提供这里要求的 Aurabot 主人私聊作用域、模拟业务结果与原 QQ 回传闭环。这里复用原生 follow-up,不另造通信协议或引入跨进程桥。
|
|
25
|
+
|
|
26
|
+
## 数据和状态
|
|
27
|
+
|
|
28
|
+
`module/index.mjs` 是独立用户模块;`module/ledger.mjs` 保存独立账本。任务含唯一 ID、来源 QQ 对话和 DSH 会话 ID、固定目标 ID、模拟类型、时间、结果和每次回传记录。文件通过临时文件、同步与改名提交;权限 0600。不保存聊天正文、不读取 Aurabot 的整份配置、不接触凭据。单 desktop 宿主运行,不应让多个进程共享同一账本。
|
|
29
|
+
|
|
30
|
+
- 接收:`queued` 表示本地账本已接收;确认后也会重新进入队列。
|
|
31
|
+
- 执行:`running`、`completed`、`failed`、`timed_out`、`needs_confirmation`、`interrupted`。只有同一模拟任务的工具结果和匹配的正常轮次结束才算完成。确认须由同一主人在原私聊发出完整的 `确认交接 <任务 ID>`,十分钟内有效。此确认仅用于模拟流程。
|
|
32
|
+
- 回传:待投递、已提交原会话、原会话已生成回复、结果不明、关闭回传或过期。正常回复链路没有供模块使用的 QQ 发送回执;**生成回复不代表 QQ 已发送,更不代表对方已读**。结果不明不自动补发。可在主人私聊询问「查交接 <任务 ID>」;这是最小的取回方式,不冒险另外发一条重复通知。
|
|
33
|
+
- 同一模型输入重试提交复用任务 ID;同一模拟步骤持久化领取后只能执行一次;通知先写入投递记录再提交。进程或模块中断后,未结束的任务记为中断,不自动重跑;已提交/结果不明的通知不重发。已经排入 DSH inbox 的消息属于 DSH,无法撤回。暂不支持跨进程、冷 Jarvis 自动恢复、真实 QQ 回执或 OneBot 入口去重保证。
|
|
34
|
+
- Jarvis 试验轮只暴露一个无害模拟工具。使用 DSH 的临时限制屏蔽继承的工具,工具守卫拒绝其他工具调用。对于从预设继承的 PTC,试验轮用 `presentAs('native')` 临时以原生方式呈现工具,轮次结束后撤销。如果存在无法切换的 PTC 声明或无法屏蔽的会话本地工具,任务会在投递前失败。回传轮也临时禁用工具调用。部署时请使用**专用的 Jarvis 测试会话,不配置真实设备工具或自动任务**。
|
|
35
|
+
|
|
36
|
+
## 安装与回滚
|
|
37
|
+
|
|
38
|
+
1. 在目标机运行只读脚本;确认运行中的 desktop 宿主、Aurabot 已激活、主人 QQ 私聊支持模块工具。检查是否已有专用 Jarvis 测试会话和准确 ID。若主人使用“纯聊天”档,先停在这里:不升级现有权限。可在另一步加入受信的显式主人私聊命令入口。
|
|
39
|
+
2. 确认扩展面板显示的用户模块目录。默认是 `~/.dsh/plugins-data/aurabot/modules/`,以面板实际目录为准。检查没有同名模块。把解包所得的 `package/module/` 复制到用户模块目录,命名为 `rez-task-handoff`,确保其中直接包含 `index.mjs` 和 `ledger.mjs`。此动作会触发 Aurabot 热加载;通常无需重启。模块设置默认未启用。请在 Aurabot 的扩展面板确认模块加载,不使用 DSH 的 `plugin add`。
|
|
40
|
+
3. 在 Aurabot 扩展面板输入自己指定的主人 QQ 与 Jarvis 完整会话 ID,先只勾选模拟交接。保持目标会话可用。启用后,提交模拟任务将调用配置中的模型,可能产生普通模型用量;自动回传开关仍关闭。真实 QQ 测试经用户确认后才开启回传,继续遵守 Aurabot 的总开关和试运行。
|
|
41
|
+
4. 回滚:先关闭模块内启用与回传开关,等待测试轮结束,再从扩展面板停用模块,并将 `modules/rez-task-handoff/` 移出扫描目录。只影响这个模块,不覆盖大肥鱼、RezSuite 或桌面应用。账本位于 Aurabot 数据目录下的 `modules-data/rez-task-handoff/tasks.json`,可备份留查;卸载不删除它。已提交到 DSH 或网关的消息不能从模块撤回。
|
|
42
|
+
|
|
43
|
+
从 beta.1 升级时,等当前测试和回传结束,停用扩展并将旧目录移出扫描目录,再按相同的模块目录名放入新版并启用。账本格式兼容。需要回滚时,按同样方法换回旧目录。查询支持完整任务编号或显示的 8 位短编号,发生短编号冲突时需提供完整编号。
|
|
44
|
+
|
|
45
|
+
## 测试与实机验收
|
|
46
|
+
|
|
47
|
+
快速单元测试(纯 Node,零依赖):
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
node --test test/module.test.mjs
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
原生组合测试:将上游 **v1.1.24** 源码解包到临时目录;在另一临时目录安装 npm 上明确锁定的 `@deepseek-ai/dsh-agent-loop-testkit@0.2.1-rc.5`、`@deepseek-ai/dsh-agent-loop@0.2.1-rc.5`、`@deepseek-ai/dsh-tools@0.2.1-rc.5`。测试加载真实模块 API、真实 DSH AgentLoop 和真实 Aurabot hub,只替换模型和网关,在临时目录写模拟数据。
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
HANDOFF_DSH_FIXTURE=/绝对路径/临时运行时 \
|
|
57
|
+
HANDOFF_AURABOT_FIXTURE=/绝对路径/aurabot-1.1.24 \
|
|
58
|
+
HANDOFF_REAL_DELAY=1 node --test test/native.test.mjs
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
2026-11-04 beta.2 结果为 11 项单元测试、1 项原生组合测试通过。Aurabot 从临时用户模块目录加载扩展。两条各约 20 秒的任务(成功和失败)约 42 秒完成,等待期间原会话能继续聊天,后续输出各发送一次到模拟 OneBot。Jarvis 从 PTC 预设继承的工具呈现方式在任务结束后恢复。单元测试覆盖超时、确认、目标丢失、源会话更换、权限隔离、重复提交、模块重启恢复、短编号查询,以及含糊的重试不入队。beta.2 尚未在目标 Mac mini 和真实 QQ 上验证,也未模拟整机断电。Aurabot 面板自身的模拟会话没有真实主人私聊映射。
|
|
62
|
+
|
|
63
|
+
实机顺序:主人私聊发送「让 Jarvis 做交接测试 success」→ 拿到编号 → 立即再聊一句 → 等结果;接着连续发两条(等各自收到编号,避免上游合并为一轮),再分别测 failure、timeout、confirmation。最后用同一任务 ID 查询状态,并验证非主人和群聊不会产生任务。断电/重启测试先把相关工作保存,确认测试实例处于安全状态后单独做。
|
package/inspect.mjs
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Read-only. Print selected version/presence fields, never profile contents,
|
|
3
|
+
// process arguments, auth/config files, environment values or session logs.
|
|
4
|
+
import { execFileSync } from 'node:child_process'
|
|
5
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
6
|
+
import { homedir } from 'node:os'
|
|
7
|
+
import { join } from 'node:path'
|
|
8
|
+
|
|
9
|
+
const home = process.argv[2] || process.env.DSH_HOME || join(homedir(), '.dsh')
|
|
10
|
+
const app = process.argv[3] || '/Applications/DeepSeek Harness.app'
|
|
11
|
+
const desktop = join(home, 'profiles', 'desktop')
|
|
12
|
+
const manifest = file => {
|
|
13
|
+
try { return JSON.parse(readFileSync(file, 'utf8')) } catch { return undefined }
|
|
14
|
+
}
|
|
15
|
+
const version = v => typeof v === 'string' && /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(v) ? v : null
|
|
16
|
+
const profile = manifest(join(desktop, 'package.json'))
|
|
17
|
+
const bundles = profile?.dsh?.profile?.bundles
|
|
18
|
+
const plugins = {}
|
|
19
|
+
for (const name of ['dsh-aurabot-qq', '@rezti/dsh-rez-suite']) {
|
|
20
|
+
plugins[name] = {
|
|
21
|
+
packageVersion: version(manifest(join(desktop, 'node_modules', name, 'package.json'))?.version),
|
|
22
|
+
inDesktopBundleList: Array.isArray(bundles) ? bundles.includes(name) : null,
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function getStdout(program, args) {
|
|
26
|
+
try {
|
|
27
|
+
return execFileSync(program, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 3000 })
|
|
28
|
+
} catch { return undefined }
|
|
29
|
+
}
|
|
30
|
+
const plist = join(app, 'Contents', 'Info.plist')
|
|
31
|
+
const processNames = getStdout('/bin/ps', ['-axo', 'comm='])
|
|
32
|
+
const info = {
|
|
33
|
+
installedDesktopAppVersion: version(getStdout('/usr/libexec/PlistBuddy',
|
|
34
|
+
['-c', 'Print CFBundleShortVersionString', plist])?.trim()),
|
|
35
|
+
appExecutableSeen: processNames === undefined ? null :
|
|
36
|
+
processNames.split('\n').some(line => line.trim().startsWith(join(app, 'Contents', 'MacOS') + '/')),
|
|
37
|
+
desktopProfilePresent: Boolean(profile),
|
|
38
|
+
desktopPackages: plugins,
|
|
39
|
+
modulePathPresent: existsSync(join(process.env.AURABOT_DATA_DIR || join(home, 'plugins-data', 'aurabot'),
|
|
40
|
+
'modules', 'rez-task-handoff', 'index.mjs')),
|
|
41
|
+
requiresUiCheck: ['运行中的宿主版本和 profile', '插件面板是否已激活 Aurabot 与 RezSuite',
|
|
42
|
+
'大肥鱼主人私聊的工具档位', 'Jarvis 专用会话是否存在以及固定会话 ID'],
|
|
43
|
+
}
|
|
44
|
+
console.log(JSON.stringify(info, null, 2))
|
package/module/index.mjs
ADDED
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { setTimeout as sleep } from 'node:timers/promises'
|
|
3
|
+
import { openLedger, PENDING, setState } from './ledger.mjs'
|
|
4
|
+
|
|
5
|
+
const ID = 'rez-task-handoff'
|
|
6
|
+
const MOCK = 'rez_jarvis_simulate'
|
|
7
|
+
const MODES = ['success', 'failure', 'timeout', 'confirmation']
|
|
8
|
+
const VERSION = '1.1.24'
|
|
9
|
+
const STAMP = '[rez-task-handoff:'
|
|
10
|
+
const RUN_LIMIT = 90_000
|
|
11
|
+
const QUEUE_LIMIT = 120_000
|
|
12
|
+
const CONFIRM_LIMIT = 600_000
|
|
13
|
+
const NOTE_LIMIT = 600_000
|
|
14
|
+
const TIMEOUT_DEMO = 4000
|
|
15
|
+
const sidKey = value => String(value ?? '').replace(/^session-/, '')
|
|
16
|
+
const sameId = (a, b) => Boolean(sidKey(a) && sidKey(a) === sidKey(b))
|
|
17
|
+
const exactId = agent => String(agent?.session?.id ?? '')
|
|
18
|
+
const idPattern = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/
|
|
19
|
+
const ownerPattern = /^\d{5,15}$/
|
|
20
|
+
const submitPattern = /^(?:请\s*)?(?:让\s*Jarvis\s*(?:做|重做)\s*)?交接\s*测试(?:\s+(success|failure|timeout|confirmation))?$/i
|
|
21
|
+
const submitHint = '请在主人 QQ 私聊发送「让 Jarvis 做交接测试 success」发起一条新的模拟任务。'
|
|
22
|
+
const pluginMessage = (id, text) => ({
|
|
23
|
+
id, role: 'user', source: { kind: ID, form: 'relay' },
|
|
24
|
+
content: [{ type: 'text', text }],
|
|
25
|
+
})
|
|
26
|
+
const outcomeText = task => `任务 ${task.id}:${task.state}。 ${task.result || ''}`
|
|
27
|
+
|
|
28
|
+
// Kept separate so the native scheduler, clock and transport can be exercised without QQ or a model.
|
|
29
|
+
export function setupHandoff(api, { now = Date.now, delay = sleep, demoMs = 20_000 } = {}) {
|
|
30
|
+
const hub = api.core?.hub
|
|
31
|
+
const ctx = api.dsh
|
|
32
|
+
const agents = ctx?.get?.('agents')
|
|
33
|
+
const registry = ctx?.get?.('tools')
|
|
34
|
+
if (api.version !== VERSION || api.apiVersion !== 1 ||
|
|
35
|
+
typeof hub?.sessions !== 'function' || typeof hub?.chatOfSession !== 'function' ||
|
|
36
|
+
typeof ctx?.on !== 'function' || typeof agents?.get !== 'function' ||
|
|
37
|
+
typeof registry?.schemas !== 'function') {
|
|
38
|
+
api.log.warn('跨会话交接未启用:扩展 API 与验证版本不匹配。')
|
|
39
|
+
return
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const ledger = openLedger(api.dataDir('tasks.json'), now)
|
|
43
|
+
const permits = new Map()
|
|
44
|
+
const sourceAgents = new Map()
|
|
45
|
+
let active
|
|
46
|
+
let disposed = false
|
|
47
|
+
let broken = false
|
|
48
|
+
|
|
49
|
+
function config() {
|
|
50
|
+
const s = api.settings()
|
|
51
|
+
const owner = String(s.ownerQQ ?? '').trim()
|
|
52
|
+
const target = String(s.jarvisSessionId ?? '').trim()
|
|
53
|
+
if (disposed || broken || s.enabled !== true || !ownerPattern.test(owner) ||
|
|
54
|
+
!idPattern.test(target) || !api.isOwner(owner)) return
|
|
55
|
+
return { owner, chatId: 'private:' + owner, target, notify: s.notify === true }
|
|
56
|
+
}
|
|
57
|
+
function findAgent(id) {
|
|
58
|
+
const agent = agents.get(id) ?? agents.get(sidKey(id)) ?? agents.get('session-' + sidKey(id))
|
|
59
|
+
return sameId(exactId(agent), id) ? agent : undefined
|
|
60
|
+
}
|
|
61
|
+
function findTarget(id) {
|
|
62
|
+
const agent = agents.get(id)
|
|
63
|
+
return exactId(agent) === id ? agent : undefined
|
|
64
|
+
}
|
|
65
|
+
function mapped(chatId, sessionId) {
|
|
66
|
+
return sameId(hub.sessions()?.[chatId]?.sessionId, sessionId) && hub.chatOfSession(sessionId) === chatId
|
|
67
|
+
}
|
|
68
|
+
function isJarvis(id) {
|
|
69
|
+
return !hub.chatOfSession(id) && !Object.values(hub.sessions() ?? {}).some(row => sameId(row?.sessionId, id))
|
|
70
|
+
}
|
|
71
|
+
function changed(fn) {
|
|
72
|
+
if (disposed || broken) throw new Error('交接已停止。')
|
|
73
|
+
try { return ledger.transaction(fn) } catch {
|
|
74
|
+
broken = true
|
|
75
|
+
active?.abort.abort()
|
|
76
|
+
api.log.error('任务账本写入失败,交接已停止。请保留数据并检查磁盘。')
|
|
77
|
+
throw new Error('交接账本不可用。')
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
function finish(id, state, result) {
|
|
81
|
+
if (disposed || broken) return
|
|
82
|
+
changed(tasks => {
|
|
83
|
+
const t = tasks.find(t => t.id === id)
|
|
84
|
+
if (t?.state === 'running') setState(t, state, result, now())
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function authorize(where, exec) {
|
|
89
|
+
const c = config()
|
|
90
|
+
const agent = exec?.agent
|
|
91
|
+
const id = exactId(agent)
|
|
92
|
+
if (!c || where.chat.kind !== 'private' || where.chat.chatId !== c.chatId ||
|
|
93
|
+
!sameId(id, where.sessionId) || !mapped(c.chatId, id) || sameId(id, c.target) ||
|
|
94
|
+
typeof agent?.session?.eventAt !== 'function') return
|
|
95
|
+
const session = agent.session
|
|
96
|
+
// Check the latest turn only, using identifiers issued by the trusted prompt hook.
|
|
97
|
+
for (let seq = session.seq - 1; seq >= Math.max(0, session.seq - 128); seq--) {
|
|
98
|
+
const e = session.eventAt(seq)
|
|
99
|
+
if (e?.type === 'turn/start' || e?.type === 'turn/end') break
|
|
100
|
+
if (e?.type !== 'user/message') continue
|
|
101
|
+
if (e.data?.source?.kind === 'runtime-context') continue
|
|
102
|
+
if (e.data?.source?.kind !== 'aurabot') return
|
|
103
|
+
for (const block of e.data.content ?? []) {
|
|
104
|
+
if (block?.type !== 'text' || typeof block.text !== 'string' || !block.text.startsWith(STAMP)) continue
|
|
105
|
+
const permit = permits.get(block.text)
|
|
106
|
+
if (!permit || !sameId(permit.sessionId, id) || permit.chatId !== c.chatId ||
|
|
107
|
+
now() - permit.at >= QUEUE_LIMIT || !e.data.id) continue
|
|
108
|
+
// A copied marker cannot become a fresh authorization for another message.
|
|
109
|
+
if (permit.messageId && permit.messageId !== e.data.id) continue
|
|
110
|
+
permit.messageId = e.data.id
|
|
111
|
+
return { c, id, messageId: e.data.id, confirm: permit.confirm, requested: permit.requested }
|
|
112
|
+
}
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
api.on('prompt', (content, info) => {
|
|
118
|
+
const c = config()
|
|
119
|
+
const sid = String(info?.sessionId ?? '')
|
|
120
|
+
if (!c || !sid || info.chat?.kind !== 'private' ||
|
|
121
|
+
info.chat.chatId !== c.chatId || String(info.senderId) !== c.owner ||
|
|
122
|
+
!mapped(c.chatId, sid)) return
|
|
123
|
+
const source = findAgent(sid)
|
|
124
|
+
if (!source || sameId(exactId(source), c.target)) return
|
|
125
|
+
sourceAgents.set(c.chatId, source)
|
|
126
|
+
const query = String(info.queryText ?? '').trim()
|
|
127
|
+
const stamp = STAMP + randomUUID() + ']'
|
|
128
|
+
const confirm = /^确认交接\s+([a-f0-9-]{36})$/i.exec(query)?.[1]?.toLowerCase()
|
|
129
|
+
for (const [key, p] of permits) if (now() - p.at >= QUEUE_LIMIT) permits.delete(key)
|
|
130
|
+
if (permits.size >= 256) permits.delete(permits.keys().next().value)
|
|
131
|
+
const request = submitPattern.exec(query)
|
|
132
|
+
permits.set(stamp, { chatId: c.chatId, sessionId: sid, at: now(), confirm,
|
|
133
|
+
requested: request ? (request[1]?.toLowerCase() ?? 'success') : undefined })
|
|
134
|
+
return [...content, { type: 'text', text: stamp }]
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
const denied = () => ({ ok: false, message: '交接只接受已授权的主人 QQ 私聊消息。' })
|
|
138
|
+
const result = t => ({ ok: true, taskId: t.id, status: t.state, message: outcomeText(t) })
|
|
139
|
+
api.tool(where => {
|
|
140
|
+
const c = config()
|
|
141
|
+
if (!c || where.chat?.kind !== 'private' || where.chat.chatId !== c.chatId) return []
|
|
142
|
+
return [
|
|
143
|
+
api.defineTool({
|
|
144
|
+
name: 'rez_handoff_submit',
|
|
145
|
+
description: '提交主人明确指定的 Jarvis 模拟交接。以主人当条消息指定的模式为准,立即返回任务编号。',
|
|
146
|
+
properties: { mode: { type: 'string', enum: MODES } },
|
|
147
|
+
required: ['mode'],
|
|
148
|
+
execute(args, exec) {
|
|
149
|
+
const auth = authorize(where, exec)
|
|
150
|
+
if (!auth) return denied()
|
|
151
|
+
if (!auth.requested) return { ok: false, message: submitHint }
|
|
152
|
+
if (!MODES.includes(args?.mode)) return { ok: false, message: '只支持四种模拟任务。' }
|
|
153
|
+
const existing = ledger.tasks().find(t => t.sourceSessionId === auth.id && t.requestId === auth.messageId)
|
|
154
|
+
if (existing) return result(existing)
|
|
155
|
+
const target = findTarget(auth.c.target)
|
|
156
|
+
if (!target || !isJarvis(auth.c.target)) {
|
|
157
|
+
return { ok: false, message: '固定 Jarvis 会话未就绪。请先在桌面打开专用会话。' }
|
|
158
|
+
}
|
|
159
|
+
if (ledger.tasks().filter(t => PENDING.has(t.state)).length >= 10) {
|
|
160
|
+
return { ok: false, message: '待处理任务已满。' }
|
|
161
|
+
}
|
|
162
|
+
const t = changed(tasks => {
|
|
163
|
+
const t = {
|
|
164
|
+
id: randomUUID(), sourceChatId: auth.c.chatId, sourceSessionId: auth.id,
|
|
165
|
+
requestId: auth.messageId, targetSessionId: auth.c.target,
|
|
166
|
+
mode: auth.requested, state: 'queued', createdAt: now(), updatedAt: now(), notices: [],
|
|
167
|
+
}
|
|
168
|
+
tasks.push(t)
|
|
169
|
+
return t
|
|
170
|
+
})
|
|
171
|
+
return { ...result(t), message: `已接收模拟任务 ${t.id},正在排队;完成后再通知。` }
|
|
172
|
+
},
|
|
173
|
+
}),
|
|
174
|
+
api.defineTool({
|
|
175
|
+
name: 'rez_handoff_status',
|
|
176
|
+
description: '查看主人私聊提交的任务状态与回传状态。接受完整编号或显示的 8 位短编号,省略则查询最新一条。',
|
|
177
|
+
properties: { taskId: { type: 'string' } },
|
|
178
|
+
execute(args, exec) {
|
|
179
|
+
const auth = authorize(where, exec)
|
|
180
|
+
if (!auth) return denied()
|
|
181
|
+
const taskId = String(args?.taskId ?? '').trim().toLowerCase()
|
|
182
|
+
const short = /^[a-f0-9]{8}$/.test(taskId)
|
|
183
|
+
const tasks = ledger.tasks().filter(t => t.sourceChatId === auth.c.chatId &&
|
|
184
|
+
(!taskId || (short ? t.id.startsWith(taskId) : t.id === taskId)))
|
|
185
|
+
if (short && tasks.length > 1) return { ok: false, message: '短编号对应多条任务,请提供完整任务编号。' }
|
|
186
|
+
const t = tasks.at(-1)
|
|
187
|
+
if (!t) return { ok: false, message: '未找到该任务。' }
|
|
188
|
+
const notice = t.notices.at(-1)
|
|
189
|
+
return { ...result(t), notification: notice?.state ?? 'not_ready',
|
|
190
|
+
message: outcomeText(t) + ' 回传状态:' + ({
|
|
191
|
+
pending: '待投递', dispatching: '准备交给原会话',
|
|
192
|
+
submitted: '已交给原会话,QQ 投递未核实',
|
|
193
|
+
unknown: '投递结果不明,不自动重发',
|
|
194
|
+
suppressed: '当前配置关闭自动回传', expired: '回传已过期',
|
|
195
|
+
rendered: '原会话已生成回复,QQ 投递未核实',
|
|
196
|
+
}[notice?.state] ?? '尚无回传') }
|
|
197
|
+
},
|
|
198
|
+
}),
|
|
199
|
+
api.defineTool({
|
|
200
|
+
name: 'rez_handoff_confirm',
|
|
201
|
+
description: '仅在主人明确说「确认交接 <完整任务编号>」时确认该模拟任务。',
|
|
202
|
+
properties: { taskId: { type: 'string' } },
|
|
203
|
+
required: ['taskId'],
|
|
204
|
+
execute(args, exec) {
|
|
205
|
+
const auth = authorize(where, exec)
|
|
206
|
+
if (!auth || !auth.confirm || auth.confirm !== args?.taskId) return denied()
|
|
207
|
+
const t = changed(tasks => {
|
|
208
|
+
const t = tasks.find(t => t.id === args.taskId && t.sourceChatId === auth.c.chatId &&
|
|
209
|
+
t.sourceSessionId === auth.id && t.targetSessionId === auth.c.target)
|
|
210
|
+
if (!t || t.state !== 'needs_confirmation' || now() - t.updatedAt >= CONFIRM_LIMIT) return
|
|
211
|
+
for (const n of t.notices) if (n.state === 'pending') n.state = 'superseded'
|
|
212
|
+
Object.assign(t, { confirmed: true, state: 'queued', updatedAt: now(), result: '' })
|
|
213
|
+
delete t.run
|
|
214
|
+
return t
|
|
215
|
+
})
|
|
216
|
+
return t ? { ...result(t), message: '已确认,模拟任务重新入队。' } :
|
|
217
|
+
{ ok: false, message: '任务不在等待确认状态,或确认已过期。' }
|
|
218
|
+
},
|
|
219
|
+
}),
|
|
220
|
+
]
|
|
221
|
+
})
|
|
222
|
+
api.rule(kind => kind === 'private' && config()
|
|
223
|
+
? '主人明确说「让 Jarvis 做交接测试 success」或指定 failure、timeout、confirmation 时用 rez_handoff_submit,保留完整任务编号。询问 Jarvis 能做什么时,说明本模块只支持这四种模拟,不提交任务。「再试一下」本身不提交任务,可提示主人发一条明确的模拟测试请求。查交接状态用 rez_handoff_status。只有工具确认入队时才说已入队。结果回传轮只简短告知结果,不调用工具。' : '')
|
|
224
|
+
|
|
225
|
+
function release(run) {
|
|
226
|
+
if (active === run) active = undefined
|
|
227
|
+
for (const off of run.offs.reverse()) {
|
|
228
|
+
try { off() } catch { /* agent may already be disposed */ }
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function launch(task, agent) {
|
|
233
|
+
const tools = agent.ctx?.tools
|
|
234
|
+
const run = { id: task.id, target: exactId(agent), messageId: randomUUID(), abort: new AbortController(), offs: [],
|
|
235
|
+
deadline: now() + (task.mode === 'timeout' ? TIMEOUT_DEMO : RUN_LIMIT) }
|
|
236
|
+
active = run
|
|
237
|
+
let isolationError = 'Jarvis 工具隔离接口不可用,请使用专用的标准会话。'
|
|
238
|
+
try {
|
|
239
|
+
if (typeof tools?.guard !== 'function' || typeof tools?.register !== 'function' ||
|
|
240
|
+
typeof tools?.restrict !== 'function' || typeof agent.whenIdle !== 'function') {
|
|
241
|
+
throw new Error('Jarvis 的工具隔离接口不可用。')
|
|
242
|
+
}
|
|
243
|
+
if (registry.schemas(agent).some(t => t.name === 'run_code')) {
|
|
244
|
+
isolationError = 'Jarvis 的 PTC 工具模式无法临时切换,请使用专用的标准会话。'
|
|
245
|
+
run.offs.push(tools.presentAs('native'))
|
|
246
|
+
}
|
|
247
|
+
isolationError = 'Jarvis 工具隔离检查失败,请使用专用的标准会话。'
|
|
248
|
+
run.offs.push(tools.restrict({ allow: [] }))
|
|
249
|
+
run.offs.push(tools.guard(exec => exec.name === MOCK ? undefined : 'Jarvis 模拟会话禁止使用其他工具。'))
|
|
250
|
+
run.offs.push(tools.register(api.defineTool({
|
|
251
|
+
name: MOCK,
|
|
252
|
+
description: '执行当前交接的唯一无害模拟步骤。无须参数;不可执行系统或设备操作。',
|
|
253
|
+
async execute(_args, exec) {
|
|
254
|
+
if (disposed || broken || !config() || active !== run ||
|
|
255
|
+
exactId(exec?.agent) !== run.target || run.turn === undefined) {
|
|
256
|
+
return { ok: false, message: '没有可执行的交接。' }
|
|
257
|
+
}
|
|
258
|
+
if (typeof exec.concludeTurn !== 'function' || !exec.signal) {
|
|
259
|
+
return { ok: false, message: '当前宿主不能安全结束模拟轮次。' }
|
|
260
|
+
}
|
|
261
|
+
const accepted = changed(tasks => {
|
|
262
|
+
const t = tasks.find(t => t.id === task.id)
|
|
263
|
+
if (t?.state !== 'running' || t.run?.messageId !== run.messageId || t.run.claimed) return false
|
|
264
|
+
t.run.claimed = true
|
|
265
|
+
return true
|
|
266
|
+
})
|
|
267
|
+
if (!accepted) return { ok: false, message: '该模拟步骤已执行或已失效。' }
|
|
268
|
+
const signal = AbortSignal.any([run.abort.signal, exec.signal])
|
|
269
|
+
await delay(task.mode === 'timeout' ? TIMEOUT_DEMO + 10_000 : demoMs, undefined, { signal })
|
|
270
|
+
if (disposed || broken || signal.aborted || active !== run) return { ok: false, message: '任务已中断。' }
|
|
271
|
+
run.outcome = task.mode === 'failure' ? ['failed', '模拟执行失败。'] :
|
|
272
|
+
task.mode === 'confirmation' && !task.confirmed
|
|
273
|
+
? ['needs_confirmation', `需要主人确认。请发送:确认交接 ${task.id}`]
|
|
274
|
+
: ['completed', 'Jarvis 的模拟步骤完成,未操作真实设备。']
|
|
275
|
+
exec.concludeTurn()
|
|
276
|
+
return { ok: true, message: run.outcome[1] }
|
|
277
|
+
},
|
|
278
|
+
})))
|
|
279
|
+
const remaining = registry.schemas(agent).map(t => t.name)
|
|
280
|
+
if (remaining.length !== 1 || remaining[0] !== MOCK) {
|
|
281
|
+
isolationError = 'Jarvis 仍有额外工具,模拟任务未投递。请使用专用的标准会话。'
|
|
282
|
+
throw new Error(isolationError)
|
|
283
|
+
}
|
|
284
|
+
changed(tasks => {
|
|
285
|
+
const t = tasks.find(t => t.id === task.id)
|
|
286
|
+
t.run = { messageId: run.messageId, deadline: run.deadline }
|
|
287
|
+
setState(t, 'running', '', now())
|
|
288
|
+
})
|
|
289
|
+
} catch {
|
|
290
|
+
try { if (!broken) changed(tasks => {
|
|
291
|
+
const t = tasks.find(t => t.id === task.id)
|
|
292
|
+
if (t && ['queued', 'running'].includes(t.state)) {
|
|
293
|
+
setState(t, 'failed', isolationError, now())
|
|
294
|
+
}
|
|
295
|
+
}) } finally { release(run) }
|
|
296
|
+
return
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Do not await either followup or whenIdle from the QQ tool.
|
|
300
|
+
const submit = () => agent.followup(pluginMessage(run.messageId,
|
|
301
|
+
`交接测试 ${task.id}。只调用一次 ${MOCK},不要自行执行任何设备、文件或系统操作。`))
|
|
302
|
+
try {
|
|
303
|
+
const accepted = typeof agents.withoutInitiator === 'function' ? agents.withoutInitiator(submit) : submit()
|
|
304
|
+
Promise.resolve(accepted).catch(() => finish(task.id, 'failed', 'Jarvis 未接受模拟轮次。'))
|
|
305
|
+
.finally(() => agent.whenIdle())
|
|
306
|
+
.catch(() => {})
|
|
307
|
+
.finally(() => {
|
|
308
|
+
try { finishIfRunning(run) } finally { release(run) }
|
|
309
|
+
}).catch(() => {})
|
|
310
|
+
} catch {
|
|
311
|
+
// A thrown send does not prove nothing was enqueued. Keep the guard until idle.
|
|
312
|
+
try { finish(task.id, 'failed', 'Jarvis 轮次提交结果不明,未重试。') } finally {
|
|
313
|
+
Promise.resolve().then(() => agent.whenIdle()).then(() => release(run), () => {}).catch(() => {})
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function finishIfRunning(run) {
|
|
319
|
+
if (!disposed && !broken && ledger.tasks().some(t => t.id === run.id && t.state === 'running')) {
|
|
320
|
+
finish(run.id, 'failed', 'Jarvis 未产生可关联的模拟结果。')
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Follow the native turn boundary. A completed unrelated turn cannot settle this task.
|
|
325
|
+
const replies = new Map()
|
|
326
|
+
function releaseReply(sid, pending) {
|
|
327
|
+
if (replies.get(sid) !== pending) return
|
|
328
|
+
replies.delete(sid)
|
|
329
|
+
try { pending.off() } catch { /* scope may already be disposed */ }
|
|
330
|
+
}
|
|
331
|
+
const off = ctx.on('session/event', (session, event) => {
|
|
332
|
+
if (disposed || broken) return
|
|
333
|
+
const sid = String(session?.id ?? '')
|
|
334
|
+
const run = active
|
|
335
|
+
try {
|
|
336
|
+
if (run && sid === run.target) {
|
|
337
|
+
if (event.type === 'turn/start') run.openTurn = event.data.turn
|
|
338
|
+
if (event.type === 'user/message' && event.data.id === run.messageId) run.turn = run.openTurn
|
|
339
|
+
if (event.type === 'turn/end' && event.data.turn === run.turn) {
|
|
340
|
+
const out = now() >= run.deadline ? ['timed_out', '模拟任务超时,未自动重试。'] :
|
|
341
|
+
event.data.reason?.kind === 'completed' ? run.outcome : undefined
|
|
342
|
+
finish(run.id, ...(out ?? ['failed', 'Jarvis 轮次未正常完成,或没有执行模拟步骤。']))
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
const pending = replies.get(sid)
|
|
346
|
+
if (!pending) return
|
|
347
|
+
if (event.type === 'turn/start') pending.openTurn = event.data.turn
|
|
348
|
+
if (event.type === 'user/message' && event.data.id === pending.id) pending.turn = pending.openTurn
|
|
349
|
+
if (event.type === 'assistant/message' && event.data.turn === pending.turn && !event.data.interrupted &&
|
|
350
|
+
event.data.message?.content?.some(b => b.type === 'text' && b.text?.trim())) pending.hasText = true
|
|
351
|
+
if (event.type === 'turn/end' && event.data.turn === pending.turn) {
|
|
352
|
+
const state = event.data.reason?.kind === 'completed' && pending.hasText ? 'rendered' : 'unknown'
|
|
353
|
+
try { markNotice(pending.taskId, pending.id, state) } finally { releaseReply(sid, pending) }
|
|
354
|
+
}
|
|
355
|
+
} catch { /* ledger errors already disable the module; never break the host listener */ }
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
function markNotice(taskId, noticeId, state) {
|
|
359
|
+
changed(tasks => {
|
|
360
|
+
const n = tasks.find(t => t.id === taskId)?.notices.find(n => n.id === noticeId)
|
|
361
|
+
if (n && ['pending', 'dispatching', 'submitted'].includes(n.state)) Object.assign(n, { state, updatedAt: now() })
|
|
362
|
+
})
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function notify(tasks, c) {
|
|
366
|
+
for (const t of tasks) {
|
|
367
|
+
if (t.sourceChatId !== c.chatId || t.targetSessionId !== c.target) continue
|
|
368
|
+
const n = t.notices.find(n => n.state === 'pending')
|
|
369
|
+
if (!n) continue
|
|
370
|
+
if (now() - n.createdAt >= NOTE_LIMIT) { markNotice(t.id, n.id, 'expired'); continue }
|
|
371
|
+
if (!c.notify) { markNotice(t.id, n.id, 'suppressed'); continue }
|
|
372
|
+
// Pin the original session. Never ask Aurabot to create/rotate a session for a notification.
|
|
373
|
+
const source = sourceAgents.get(c.chatId)
|
|
374
|
+
const current = findAgent(t.sourceSessionId)
|
|
375
|
+
if (!source || source !== current || !mapped(c.chatId, t.sourceSessionId) || source.status !== 'idle' ||
|
|
376
|
+
replies.has(t.sourceSessionId) || api.core.mutes?.muted?.(c.chatId)) continue
|
|
377
|
+
if (typeof source.ctx?.tools?.guard !== 'function' || typeof source.whenIdle !== 'function') continue
|
|
378
|
+
const note = `【交接系统通知】任务 ${t.id},状态 ${n.outcome}。 ${n.result}\n请结合本 QQ 会话,用简短自然的话告知主人,保留任务编号和真实状态。不要调用工具。`
|
|
379
|
+
const off = source.ctx.tools.guard(() => '交接回传轮只允许生成文字。')
|
|
380
|
+
const pending = { taskId: t.id, id: n.id, at: now(), off }
|
|
381
|
+
replies.set(t.sourceSessionId, pending)
|
|
382
|
+
try {
|
|
383
|
+
markNotice(t.id, n.id, 'dispatching')
|
|
384
|
+
try {
|
|
385
|
+
Promise.resolve(source.followup(pluginMessage(n.id, note))).then(
|
|
386
|
+
() => { if (!disposed && !broken) markNotice(t.id, n.id, 'submitted') },
|
|
387
|
+
() => { if (!disposed && !broken) markNotice(t.id, n.id, 'unknown') },
|
|
388
|
+
).catch(() => {})
|
|
389
|
+
} catch { markNotice(t.id, n.id, 'unknown') }
|
|
390
|
+
Promise.resolve().then(() => source.whenIdle()).then(() => {
|
|
391
|
+
if (replies.get(t.sourceSessionId) !== pending) return
|
|
392
|
+
try { if (!disposed && !broken) markNotice(t.id, n.id, 'unknown') }
|
|
393
|
+
finally { releaseReply(t.sourceSessionId, pending) }
|
|
394
|
+
}).catch(() => {})
|
|
395
|
+
} catch {
|
|
396
|
+
releaseReply(t.sourceSessionId, pending)
|
|
397
|
+
}
|
|
398
|
+
return
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function tick() {
|
|
403
|
+
if (disposed || broken) return
|
|
404
|
+
const c = config()
|
|
405
|
+
const rows = ledger.tasks()
|
|
406
|
+
for (const note of replies.values()) if (!note.timedOut && now() - note.at >= RUN_LIMIT) {
|
|
407
|
+
note.timedOut = true
|
|
408
|
+
markNotice(note.taskId, note.id, 'unknown')
|
|
409
|
+
}
|
|
410
|
+
for (const t of rows) {
|
|
411
|
+
const stale = !c || c.chatId !== t.sourceChatId || t.targetSessionId !== c.target
|
|
412
|
+
if (t.state === 'running' && (stale || now() >= t.run.deadline)) {
|
|
413
|
+
finish(t.id, stale ? 'interrupted' : 'timed_out', stale ? '交接已停用或绑定已更改。' : '模拟任务超时,未自动重试。')
|
|
414
|
+
if (active?.id === t.id) active.abort.abort()
|
|
415
|
+
} else if ((t.state === 'queued' && (stale || now() - t.updatedAt >= QUEUE_LIMIT)) ||
|
|
416
|
+
(t.state === 'needs_confirmation' && now() - t.updatedAt >= CONFIRM_LIMIT)) {
|
|
417
|
+
changed(tasks => setState(tasks.find(x => x.id === t.id), stale ? 'interrupted' : 'timed_out',
|
|
418
|
+
stale ? '交接已停用或绑定已更改。' : '排队或确认等待已过期。', now()))
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
if (!c) return
|
|
422
|
+
const fresh = ledger.tasks()
|
|
423
|
+
if (!active) {
|
|
424
|
+
const t = fresh.find(t => t.state === 'queued')
|
|
425
|
+
const target = t && findTarget(t.targetSessionId)
|
|
426
|
+
if (target?.status === 'idle' && isJarvis(t.targetSessionId)) launch(t, target)
|
|
427
|
+
}
|
|
428
|
+
notify(fresh, c)
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
api.every(1000, tick, '交接后台处理')
|
|
432
|
+
api.onDispose(() => {
|
|
433
|
+
disposed = true
|
|
434
|
+
off()
|
|
435
|
+
active?.abort.abort()
|
|
436
|
+
permits.clear()
|
|
437
|
+
sourceAgents.clear()
|
|
438
|
+
})
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
export default {
|
|
442
|
+
id: ID,
|
|
443
|
+
name: 'Jarvis 跨会话交接(模拟)',
|
|
444
|
+
version: '0.1.0-beta.2',
|
|
445
|
+
api: 1,
|
|
446
|
+
description: '把主人私聊的模拟任务交给指定 Jarvis 会话并回传结果。',
|
|
447
|
+
settings: [
|
|
448
|
+
{ key: 'enabled', label: '启用模拟交接', type: 'switch' },
|
|
449
|
+
{ key: 'ownerQQ', label: '主人 QQ 号(须与大肥鱼设置一致)', type: 'text' },
|
|
450
|
+
{ key: 'jarvisSessionId', label: 'Jarvis 会话 ID', type: 'text' },
|
|
451
|
+
{ key: 'notify', label: '允许回到原 QQ 会话主动通知', type: 'switch' },
|
|
452
|
+
],
|
|
453
|
+
setup: setupHandoff,
|
|
454
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { closeSync, fsyncSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { dirname, join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
const MAX_TASKS = 1000
|
|
6
|
+
export const PENDING = new Set(['queued', 'running', 'needs_confirmation'])
|
|
7
|
+
|
|
8
|
+
export function setState(task, state, result, now) {
|
|
9
|
+
Object.assign(task, { state, result, updatedAt: now })
|
|
10
|
+
if (state !== 'queued' && state !== 'running') {
|
|
11
|
+
task.notices.push({ id: randomUUID(), state: 'pending', outcome: state, result, createdAt: now, updatedAt: now })
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function openLedger(path, now = Date.now) {
|
|
16
|
+
let snapshot = { version: 1, tasks: [] }
|
|
17
|
+
try {
|
|
18
|
+
snapshot = JSON.parse(readFileSync(path, 'utf8'))
|
|
19
|
+
} catch (error) {
|
|
20
|
+
if (error.code !== 'ENOENT') throw new Error('无法读取任务账本;请保留文件并停用模块排查。')
|
|
21
|
+
}
|
|
22
|
+
if (snapshot?.version !== 1 || !Array.isArray(snapshot.tasks) || snapshot.tasks.length > MAX_TASKS ||
|
|
23
|
+
snapshot.tasks.some(task => !task || typeof task.id !== 'string' || !Array.isArray(task.notices))) {
|
|
24
|
+
throw new Error('无法识别任务账本;请保留文件并停用模块排查。')
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function transaction(fn) {
|
|
28
|
+
const next = structuredClone(snapshot)
|
|
29
|
+
const result = fn(next.tasks)
|
|
30
|
+
if (next.tasks.length > MAX_TASKS) throw new Error('任务账本已满,请停用模块后备份归档。')
|
|
31
|
+
const tmp = join(dirname(path), '.handoff-' + randomUUID() + '.tmp')
|
|
32
|
+
try {
|
|
33
|
+
const fd = openSync(tmp, 'wx', 0o600)
|
|
34
|
+
try {
|
|
35
|
+
writeFileSync(fd, JSON.stringify(next) + '\n', 'utf8')
|
|
36
|
+
fsyncSync(fd)
|
|
37
|
+
} finally {
|
|
38
|
+
closeSync(fd)
|
|
39
|
+
}
|
|
40
|
+
renameSync(tmp, path)
|
|
41
|
+
snapshot = next
|
|
42
|
+
} finally {
|
|
43
|
+
try { unlinkSync(tmp) } catch (error) { if (error.code !== 'ENOENT') throw error }
|
|
44
|
+
}
|
|
45
|
+
return structuredClone(result)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (snapshot.tasks.some(t => ['queued', 'running'].includes(t.state) ||
|
|
49
|
+
t.notices.some(n => n.state === 'dispatching'))) {
|
|
50
|
+
transaction(tasks => {
|
|
51
|
+
for (const t of tasks) {
|
|
52
|
+
if (['queued', 'running'].includes(t.state)) {
|
|
53
|
+
setState(t, 'interrupted', '模块中断,未自动重跑。', now())
|
|
54
|
+
}
|
|
55
|
+
for (const notice of t.notices) {
|
|
56
|
+
if (notice.state === 'dispatching') {
|
|
57
|
+
notice.state = 'unknown'
|
|
58
|
+
notice.updatedAt = now()
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return { tasks: () => structuredClone(snapshot.tasks), transaction }
|
|
66
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,27 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rezti/dsh-rez-task-handoff",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0-beta.2",
|
|
4
|
+
"description": "Aurabot user module for simulated cross-session Jarvis task handoffs in DeepSeek Harness.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./module/index.mjs",
|
|
7
|
+
"exports": "./module/index.mjs",
|
|
8
|
+
"engines": {
|
|
9
|
+
"node": ">=22.13.0"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"inspect.mjs",
|
|
13
|
+
"module/index.mjs",
|
|
14
|
+
"module/ledger.mjs"
|
|
15
|
+
],
|
|
16
|
+
"license": "Apache-2.0",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/ReZ-TI/deepseek-harness.git",
|
|
20
|
+
"directory": "extensions/rez-task-handoff"
|
|
21
|
+
},
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"registry": "https://registry.npmjs.org/",
|
|
24
|
+
"access": "public",
|
|
25
|
+
"tag": "beta"
|
|
26
|
+
}
|
|
27
|
+
}
|