@forsion/tangu-computer-use 0.6.0 → 0.6.1

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.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # 更新日志
2
2
 
3
+ ## 0.6.1 — 2026-10-02
4
+
5
+ - 修复:在 Forsion 里停用再启用 Computer Use 插件(引擎不重启的热插拔)时,每次启用都会多挂一组进程退出监听,开关拨多了会报 `MaxListenersExceededWarning`,退出时收尾也会重复执行。现在停用会摘掉这组监听,再启用也只挂一组。
6
+ - `types/tangu-agent.d.ts` 同步到引擎当前的插件 API(`requiresPlugins` 与生命周期)。
7
+ - 新增 `npm run check:exit-listeners`(已并入 `npm run check`)。
8
+
9
+ Turning the Computer Use plugin off and on again in Forsion, without restarting the engine, no longer adds another set of process exit listeners each time. Repeated toggling used to trigger `MaxListenersExceededWarning` and run the cleanup several times on exit. Deactivating the plugin now removes its listeners, and enabling it again adds a single set. `types/tangu-agent.d.ts` now matches the engine's current plugin API (`requiresPlugins` and the lifecycle). New `npm run check:exit-listeners`, included in `npm run check`.
10
+
3
11
  ## 0.6.0 — 2026-09-27
4
12
 
5
13
  - 新增「电脑历史」采集(macOS,协议 13):Forsion 桌面端开启该功能后,helper 通过新的 `recordSubscribe` 订阅,把前台 App 切换、窗口标题、浏览器网址(去掉查询串)、焦点输入框里新打的字、点击的控件名和 ⌘ / ⌃ 快捷键推给桌面端。只需要「辅助功能」授权,不截图、不录原始按键。helper 自己不落盘,保存、保留期、清除、暂停都由桌面端负责;最后一个订阅者断开后,所有观察者随即拆除。
package/manifest.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "id": "tangu-computer-use",
3
3
  "name": "电脑操作",
4
4
  "nameEn": "Computer Use",
5
- "version": "0.6.0",
5
+ "version": "0.6.1",
6
6
  "apiVersion": 1,
7
7
  "minAppVersion": "2.11.2",
8
8
  "description": "让 agent 观察并操作桌面应用:看屏幕、点按、输入、滚动(macOS / Windows / Linux)。捆绑包内含引擎侧的 12 个工具、配套技能,以及一个「被操控的窗口」实时画面视图 —— agent 动手时,被操控的那个窗口会亮起边缘光效,画面同步出现在 Agent Desk 上。",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forsion/tangu-computer-use",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "description": "Tangu engine plugin: let agents observe and control macOS, Windows, and Linux desktop apps. Fork of injaneity/pi-computer-use (MIT).",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -54,7 +54,7 @@
54
54
  "check:platform": "node scripts/platform-contract.check.mjs",
55
55
  "check:blindclick": "node scripts/blind-click.check.mjs",
56
56
  "check:liveview": "node scripts/live-view.check.mjs",
57
- "check": "npm run check:platform && npm run check:no-foreground && npm run check:helper-path && npm run check:highlight && npm run check:permissions && npm run check:plugin && npm run check:helper-refresh && npm run check:installer && npm run check:cli-exit && npm run check:recorder-logic",
57
+ "check": "npm run check:platform && npm run check:no-foreground && npm run check:helper-path && npm run check:highlight && npm run check:permissions && npm run check:plugin && npm run check:helper-refresh && npm run check:installer && npm run check:cli-exit && npm run check:recorder-logic && npm run check:exit-listeners",
58
58
  "build:native": "node scripts/build-native.mjs",
59
59
  "build:windows": "node scripts/build-native.mjs --platform windows",
60
60
  "build:linux": "node scripts/build-native.mjs --platform linux",
@@ -65,6 +65,7 @@
65
65
  "check:helper-signal": "node scripts/helper-signal.check.mjs",
66
66
  "check:installer": "node scripts/keychain-free-install.check.mjs",
67
67
  "check:cli-exit": "node scripts/cli-exit.check.mjs",
68
+ "check:exit-listeners": "node scripts/exit-listeners.check.mjs",
68
69
  "check:recorder-logic": "node scripts/recorder-logic.check.mjs",
69
70
  "check:recorder": "node scripts/recorder.check.mjs"
70
71
  },
Binary file
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "bundleId": "com.forsion.tangu-computer-use",
3
- "version": "0.6.0",
4
- "archiveSha256": "2888e7908a0f5cb3d9e2ae9dd26d4c53737d7cf0550eb7d1afccf58a773fda0a",
3
+ "version": "0.6.1",
4
+ "archiveSha256": "2be40b9a3e4f631149e20386407f7d5fecd43629e74bb72ec0b9a7bbae8fa111",
5
5
  "files": {
6
- "Contents/MacOS/bridge": "1fac72ce2c0491c6191a877bc7ec373d17c0bc371d3ffe67cd27b11739eb94ef",
7
- "Contents/Info.plist": "69122c51686853486a2453f8ac3ade5167f4a05b8614f235186aadbe1431b398",
6
+ "Contents/MacOS/bridge": "7536d08e0c6f7af89546b8c99c0880138f09e7bfda8cc774095d36a325a4a568",
7
+ "Contents/Info.plist": "2709584b447ddc4c49681e787e1c13a87050d4247a3c477a2d91098b846535a3",
8
8
  "Contents/_CodeSignature/CodeResources": "6686de10a28a2fe11b36cbb86dcbacc827cfc4ea116b4dabf1845e5aee629e9b"
9
9
  }
10
10
  }
Binary file
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "bundleId": "com.forsion.tangu-computer-use",
3
- "version": "0.6.0",
4
- "archiveSha256": "aa25102c14e4777326ede78a71aa9bc4e452b4da1255c2849b5c248173708a52",
3
+ "version": "0.6.1",
4
+ "archiveSha256": "170dbcf0a0eb20b69096f0857bbd2cdbfd83d5f6cbcee36c9843481eca7ecf1e",
5
5
  "files": {
6
- "Contents/MacOS/bridge": "8c09d6c17e70f329c7958cff4db15c1308bc97e73b42844f33c31de0ebf48318",
7
- "Contents/Info.plist": "69122c51686853486a2453f8ac3ade5167f4a05b8614f235186aadbe1431b398",
6
+ "Contents/MacOS/bridge": "075e7c756e3ca0c093383bb7ccfe5d69f96d3e07ecd1a974ab40c9c1a6bec55e",
7
+ "Contents/Info.plist": "2709584b447ddc4c49681e787e1c13a87050d4247a3c477a2d91098b846535a3",
8
8
  "Contents/_CodeSignature/CodeResources": "6686de10a28a2fe11b36cbb86dcbacc827cfc4ea116b4dabf1845e5aee629e9b"
9
9
  }
10
10
  }
Binary file
@@ -0,0 +1,61 @@
1
+ #!/usr/bin/env node
2
+ // 插件热插拔下的进程退出监听。宿主停用插件时调 deactivate(),再启用会在**同一个模块对象**上再调 activate(ctx);
3
+ // 同 id 升级则 import 一份新模块(新一代)。钉住 src/index.ts 挂的 exit / SIGTERM / SIGINT 监听:
4
+ // 同一模块连续 activate 每个事件只多 1 个;deactivate 回到基线(照旧收口一次);再启用又是 1 个;
5
+ // 新一代自带一份,旧一代 deactivate 只摘自己的。
6
+ // 照 cli-exit.check.mjs 的办法从源码现打一份 bundle,tools / setup / vendor/bridge 换成桩:
7
+ // 只测 activate / deactivate 的接线,不起 helper、不装任何东西。
8
+ import assert from 'node:assert/strict';
9
+ import { build } from 'esbuild';
10
+ import { mkdtempSync, rmSync } from 'node:fs';
11
+ import os from 'node:os';
12
+ import path from 'node:path';
13
+ import { fileURLToPath, pathToFileURL } from 'node:url';
14
+
15
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
16
+ const EVENTS = ['exit', 'SIGTERM', 'SIGINT'];
17
+ const FIXTURES = {
18
+ './tools.ts': `export const PLUGIN_ID = 'computer-use'; export const buildToolProvider = () => ({ tools: () => [] });`,
19
+ './setup.ts': `export async function cliMain() { return 0; }`,
20
+ './vendor/bridge.ts': `export async function shutdownComputerUseSession() { globalThis.__cuShutdowns = (globalThis.__cuShutdowns ?? 0) + 1; }`,
21
+ };
22
+
23
+ const temp = mkdtempSync(path.join(os.tmpdir(), 'cu-exit-listeners-'));
24
+ try {
25
+ const bundle = path.join(temp, 'plugin.mjs');
26
+ await build({ entryPoints: [path.join(root, 'src/index.ts')], outfile: bundle, bundle: true, platform: 'node', format: 'esm',
27
+ plugins: [{ name: 'lifecycle-fixture', setup(b) {
28
+ b.onResolve({ filter: /^\.\/(tools|setup|vendor\/bridge)\.ts$/ }, (args) => ({ path: args.path, namespace: 'fixture' }));
29
+ b.onLoad({ filter: /.*/, namespace: 'fixture' }, (args) => ({ contents: FIXTURES[args.path] }));
30
+ } }], logLevel: 'silent' });
31
+
32
+ const base = Object.fromEntries(EVENTS.map((ev) => [ev, process.listenerCount(ev)]));
33
+ const expectListeners = (extra, label) => {
34
+ for (const ev of EVENTS) assert.equal(process.listenerCount(ev), base[ev] + extra, `${label}: '${ev}' listeners`);
35
+ console.log(`PASS ${label}`);
36
+ };
37
+ const ctx = { registerPlugin() {}, registerCommand() {}, log() {}, sdk: { pluginStore: {} } };
38
+ // 带不同 query 再 import 一次 = 一份全新的模块对象,模拟同 id 升级换代。
39
+ const load = async (generation) => (await import(`${pathToFileURL(bundle).href}?generation=${generation}`)).default;
40
+
41
+ const gen1 = await load(1);
42
+ gen1.activate(ctx);
43
+ gen1.activate(ctx);
44
+ expectListeners(1, 'activating the same module twice leaves one listener per event');
45
+ gen1.deactivate();
46
+ expectListeners(0, 'deactivate removes them');
47
+ assert.equal(globalThis.__cuShutdowns, 1, 'deactivate still shuts the session down once');
48
+ gen1.activate(ctx);
49
+ expectListeners(1, 're-enabling after deactivate registers them again');
50
+
51
+ const gen2 = await load(2);
52
+ assert.notEqual(gen2, gen1, 'the second import must be a fresh module object');
53
+ gen2.activate(ctx);
54
+ expectListeners(2, 'a new generation registers its own listeners');
55
+ gen1.deactivate();
56
+ expectListeners(1, "the old generation's deactivate leaves the new generation's listeners");
57
+ gen2.deactivate();
58
+ expectListeners(0, 'the new generation deactivates back to the baseline');
59
+ } finally {
60
+ rmSync(temp, { recursive: true, force: true });
61
+ }
package/src/index.ts CHANGED
@@ -11,6 +11,14 @@ import { SETTINGS } from './settings.ts';
11
11
  import { cliMain } from './setup.ts';
12
12
  import { shutdownComputerUseSession } from './vendor/bridge.ts';
13
13
 
14
+ // helper 惰性启动后常驻;进程退出时收口(无 pi 的 session_shutdown 钩子)。
15
+ // stop 放模块级、引用唯一:宿主热插拔时会在同一个模块对象上反复 activate / deactivate(启动时每个插件先 activate、
16
+ // 未启用的随即 deactivate;停用后再启用;activate 抛错也补调一次 deactivate)。每次 activate 新建 stop 的话 deactivate
17
+ // 摘不掉,每拨一次开关叠一组监听 → MaxListenersExceededWarning、退出时 shutdown 跑 N 遍。
18
+ // 同 id 升级 import 的是新一代模块,自带一个 stop:各代只摘自己的,互不干扰。
19
+ const EXIT_EVENTS = ['exit', 'SIGTERM', 'SIGINT'] as const;
20
+ const stop = (): void => { void shutdownComputerUseSession(); };
21
+
14
22
  const plugin: TanguPlugin = {
15
23
  activate(ctx) {
16
24
  ctx.registerPlugin({
@@ -47,15 +55,17 @@ const plugin: TanguPlugin = {
47
55
  ctx.registerCommand({ name: 'computer-use', summary: 'Computer Use: setup / doctor / stop', run: cliMain });
48
56
  }
49
57
 
50
- // helper 惰性启动后常驻;进程退出时收口(无 pi 的 session_shutdown 钩子)。
51
- const stop = (): void => { void shutdownComputerUseSession(); };
52
- process.once('exit', stop);
53
- process.once('SIGTERM', stop);
54
- process.once('SIGINT', stop);
58
+ // 先摘再挂:同一模块对象上再 activate(停用后再启用、抛错后再次激活)也只挂一组。
59
+ for (const ev of EXIT_EVENTS) {
60
+ process.off(ev, stop);
61
+ process.once(ev, stop);
62
+ }
55
63
 
56
64
  ctx.log('computer-use plugin registered');
57
65
  },
58
66
  deactivate() {
67
+ // 只摘本模块自己的 stop(新一代模块与宿主的监听不动),helper 照旧收口。
68
+ for (const ev of EXIT_EVENTS) process.off(ev, stop);
59
69
  void shutdownComputerUseSession();
60
70
  },
61
71
  };
@@ -5191,9 +5191,9 @@ function waitFor(work, signal) {
5191
5191
  fn();
5192
5192
  }
5193
5193
  };
5194
- const stop = (why) => () => settle(() => reject(new Error(`${why}; the installer may still finish in the background`)));
5195
- const timer = setTimeout(stop("helper setup is taking longer than expected"), SETUP_TIMEOUT_MS);
5196
- const onAbort = stop("helper setup wait was interrupted");
5194
+ const stop2 = (why) => () => settle(() => reject(new Error(`${why}; the installer may still finish in the background`)));
5195
+ const timer = setTimeout(stop2("helper setup is taking longer than expected"), SETUP_TIMEOUT_MS);
5196
+ const onAbort = stop2("helper setup wait was interrupted");
5197
5197
  signal?.addEventListener("abort", onAbort, { once: true });
5198
5198
  work.then((v) => settle(() => resolve(v)), (e) => settle(() => reject(e instanceof Error ? e : new Error(String(e)))));
5199
5199
  });
@@ -5626,6 +5626,10 @@ async function doctor() {
5626
5626
  }
5627
5627
 
5628
5628
  // src/index.ts
5629
+ var EXIT_EVENTS = ["exit", "SIGTERM", "SIGINT"];
5630
+ var stop = () => {
5631
+ void shutdownComputerUseSession();
5632
+ };
5629
5633
  var plugin = {
5630
5634
  activate(ctx) {
5631
5635
  ctx.registerPlugin({
@@ -5654,15 +5658,14 @@ var plugin = {
5654
5658
  if (typeof ctx.registerCommand === "function") {
5655
5659
  ctx.registerCommand({ name: "computer-use", summary: "Computer Use: setup / doctor / stop", run: cliMain });
5656
5660
  }
5657
- const stop = () => {
5658
- void shutdownComputerUseSession();
5659
- };
5660
- process.once("exit", stop);
5661
- process.once("SIGTERM", stop);
5662
- process.once("SIGINT", stop);
5661
+ for (const ev of EXIT_EVENTS) {
5662
+ process.off(ev, stop);
5663
+ process.once(ev, stop);
5664
+ }
5663
5665
  ctx.log("computer-use plugin registered");
5664
5666
  },
5665
5667
  deactivate() {
5668
+ for (const ev of EXIT_EVENTS) process.off(ev, stop);
5666
5669
  void shutdownComputerUseSession();
5667
5670
  }
5668
5671
  };
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "computer-use",
3
3
  "name": "Computer Use",
4
- "version": "0.6.0",
4
+ "version": "0.6.1",
5
5
  "apiVersion": 1,
6
6
  "entry": "dist/index.js",
7
7
  "commands": ["computer-use"],
@@ -35,6 +35,44 @@ export interface ToolContext {
35
35
  * 缺省(未装配此闸)时工具应优雅降级,不要假定一定可用。 */
36
36
  collectImage?: (img: { url: string; name?: string }) => void;
37
37
  displayFile?: (item: { name: string; mime?: string; path?: string; dataUrl?: string }) => void;
38
+ /** 发起端(手机)自报的客户端能力,如 'phone.intents'(run 内冻结)。契约 tangu-agent/docs/phone-control.md §2。 */
39
+ clientCapabilities?: readonly string[];
40
+ /** 让发起端原生层执行一个动作:发 `client_cmd` → 原生 claim → 执行 → 回执。闭包已绑定本 run,只在
41
+ * clientCapabilities 非空时存在(缺省时工具应优雅降级),且**只给声明了 `clientCapability` 的工具**:
42
+ * 没声明的工具(以及任何工具的 isEnabledFor)拿到 undefined;声明了的只能发自己能力的 ns
43
+ * ('phone.intents' → 'phone'),别的 ns 立即 {ok:false, code:'undeclared'}、什么都不发。
44
+ * `opts.signal` 传 ctx.signal;run 级中止总会一并监听。
45
+ * ⚠️ 若工具声明了 capabilities.defaultTimeoutMs,它必须 ≥ claimMs + execMs —— 否则工具先被判超时,
46
+ * 手机那边却可能在稍后照样执行。 */
47
+ requestClientAction?: (req: ClientActionRequest, opts?: ClientActionOptions) => Promise<ClientActionResult>;
48
+ }
49
+
50
+ /** 客户端动作。ns 必须命中本 run 已声明的 `<ns>.*` 能力(否则立即 {ok:false, code:'undeclared'});op 由原生 verb 表解释。 */
51
+ export interface ClientActionRequest {
52
+ ns: string;
53
+ op: string;
54
+ args?: Record<string, unknown>;
55
+ }
56
+
57
+ /** claimMs:等原生 claim(缺省 15s,钳 3–30s);execMs:claim 之后等结果(缺省 20s,钳 5–120s)。 */
58
+ export interface ClientActionOptions {
59
+ claimMs?: number;
60
+ execMs?: number;
61
+ signal?: AbortSignal;
62
+ }
63
+
64
+ /** 原生回执(已消毒)或引擎自产的失败。code 恒匹配 /^[a-z_]{1,32}$/:原生码见契约 §3.4,
65
+ * 引擎自产 undeclared / not_picked_up(没接,什么都没发生)/ no_report(接了没回,可能发生了)/
66
+ * aborted(没接就被中止,什么都没发生)/ aborted_claimed(接了之后被中止,可能发生了)。 */
67
+ export interface ClientActionResult {
68
+ ok: boolean;
69
+ code?: string;
70
+ error?: string;
71
+ text?: string;
72
+ image?: string;
73
+ app?: string;
74
+ handoff?: boolean;
75
+ verified?: boolean;
38
76
  }
39
77
 
40
78
  export interface ToolCapabilities {
@@ -58,6 +96,16 @@ export interface ToolDef {
58
96
  mode?: 'sandbox' | 'host' | 'both';
59
97
  capabilities?: ToolCapabilities;
60
98
  isEnabledFor?(profile: AppProfile, ctx: ToolContext): boolean;
99
+ /** 按需装载:true = 定义默认不进工具面,系统提示「Additional Tools」目录里只留一行,模型经 load_tools 解锁。 */
100
+ deferred?: boolean;
101
+ /** 同组连坐解锁:解锁组内任一即整组解锁。 */
102
+ deferGroup?: string;
103
+ /** 目录行文案(英文一句话,带典型触发意图;缺省取 description 首行截断)。 */
104
+ deferHint?: string;
105
+ /** 客户端能力(如 'phone.intents'):本工具经 ctx.requestClientAction 让发起端原生层执行。声明后由核心中央闸
106
+ * default-deny(手机端发起 + 本 run 声明了该能力 + 非子代理 / 计划 / 通道 / 讨论),且工具名必须以 `<ns>_` 开头
107
+ * ('phone.intents' → phone_*),否则永不可见。chat 预设按能力放行,内置与插件等价。 */
108
+ clientCapability?: string;
61
109
  }
62
110
 
63
111
  export interface ToolProvider {
@@ -135,8 +183,19 @@ export interface TanguPluginContext {
135
183
  activity: { append(event: string, detail?: Record<string, unknown>): void };
136
184
  }
137
185
 
186
+ /**
187
+ * 生命周期:启动时每个插件都 activate 一次,未启用 / 前置没齐的随即 deactivate 休眠(仍列在设置页)。
188
+ * 停用、卸载、原地升级、前置消失时宿主调 `deactivate()`(限时 5s),再撤掉本次激活注册的一切(工具、命令、路由;meta 留着休眠)。
189
+ * 再启用会在**同一个模块对象**上再调一次 `activate(ctx)`:别依赖模块级「只做一次」的状态,activate 里起的定时器 /
190
+ * 子进程 / 监听都要在 deactivate 里收掉。activate 抛错则本次激活作废(回滚 + 调一次 deactivate 收尾),插件休眠不自动重试。
191
+ */
138
192
  export interface TanguPlugin {
139
- manifest?: { id: string; name: string; version: string; apiVersion: number; entry: string; commands?: string[]; description?: string };
193
+ manifest?: {
194
+ id: string; name: string; version: string; apiVersion: number; entry: string; commands?: string[]; description?: string;
195
+ /** 前置插件:`"id"` 或 `{ id, minVersion? }`。前置已启用且在跑(版本 ≥ minVersion)时本插件才激活;否则休眠,就位后自动激活。 */
196
+ requiresPlugins?: Array<string | { id: string; minVersion?: string }>;
197
+ };
140
198
  activate(ctx: TanguPluginContext): void | Promise<void>;
199
+ /** 停用 / 卸载 / 原地升级时调用(限时 5s):停掉 activate 里起的后台工作。启动期那次早于引擎装配完成,只做自身收尾。 */
141
200
  deactivate?(): void | Promise<void>;
142
201
  }