@forsion/tangu-computer-use 0.5.8 → 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.
@@ -0,0 +1,34 @@
1
+ #!/usr/bin/env node
2
+ // 电脑历史采集器(activity_recorder.swift)的单测:照 highlight / mini-foreground 的做法,临时编一个 @main 可执行文件跑一遍。
3
+ // 不需要任何授权;非 macOS 直接跳过。大部分是纯逻辑;订阅流那一组走真实订阅路径 —— 会装上 NSWorkspace 观察者与
4
+ // NSEvent 全局监听、对前台 App 发 AX 读取(没有辅助功能授权时读取失败、键盘监听收不到东西),
5
+ // 并断言最后一个订阅者走后这些全部拆掉。只断言形状,不打印事件内容。
6
+ import { execFileSync } from 'node:child_process';
7
+ import { mkdtempSync, rmSync } from 'node:fs';
8
+ import os from 'node:os';
9
+ import path from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
11
+
12
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
13
+
14
+ if (process.platform !== 'darwin') {
15
+ console.log('recorder logic check skipped (not macOS)');
16
+ process.exit(0);
17
+ }
18
+
19
+ const triple = process.arch === 'x64' ? 'x86_64-apple-macosx14.0' : 'arm64-apple-macosx14.0';
20
+ const temp = mkdtempSync(path.join(os.tmpdir(), 'cu-recorder-logic-'));
21
+ try {
22
+ execFileSync('xcrun', [
23
+ 'swiftc', '-target', triple, '-parse-as-library',
24
+ '-module-cache-path', path.join(os.tmpdir(), `tangu-cu-recorder-test-cache-${process.arch}`),
25
+ '-framework', 'AppKit',
26
+ '-framework', 'ApplicationServices',
27
+ path.join(root, 'native/macos/activity_recorder.swift'),
28
+ path.join(root, 'native/macos/activity_recorder_tests.swift'),
29
+ '-o', path.join(temp, 'test'),
30
+ ], { stdio: 'inherit' });
31
+ execFileSync(path.join(temp, 'test'), [], { stdio: 'inherit' });
32
+ } finally {
33
+ rmSync(temp, { recursive: true, force: true });
34
+ }
@@ -0,0 +1,169 @@
1
+ #!/usr/bin/env node
2
+ // 电脑历史 recordSubscribe(协议 13)的接线检查,不需要任何授权:直接起刚编好的 helper 二进制
3
+ // (serve 模式、临时 socket;不走 open,因为这里只查接线,不查 TCC 归属),然后断言:
4
+ // 1. diagnostics 报协议 13、订阅者 0,无痕扫描计数(recorderPrivateScan*)在且为 0;
5
+ // 2. recordSubscribe 的首行回包形状正确 —— ok:true + subscribed,或(dev 构建没授权时)ok:false + accessibility_denied;
6
+ // 3. 这条连接保持打开 1 秒;之后收到的每一行都是 {"ev":{t,kind}};
7
+ // 4. 订阅成功时 diagnostics 报 1 个订阅者,客户端关掉后回到 0(这里只看注册表;观察者 / 全局监听真的拆掉了
8
+ // 由 check:recorder-logic 的 debugState 断言覆盖);
9
+ // 被拒时这条连接仍是普通连接,照常应答 diagnostics;
10
+ // 5. 最后 shutdown(兜底 SIGKILL),删临时目录。
11
+ // 若本机终端恰好有辅助功能授权,订阅会真的开始记录:这里只打印事件的 kind 计数,绝不打印内容。
12
+ // 需要先 `npm run build:native`。
13
+ import assert from 'node:assert/strict';
14
+ import { spawn } from 'node:child_process';
15
+ import { existsSync, mkdtempSync, rmSync } from 'node:fs';
16
+ import net from 'node:net';
17
+ import os from 'node:os';
18
+ import path from 'node:path';
19
+ import { fileURLToPath } from 'node:url';
20
+
21
+ if (process.platform !== 'darwin') {
22
+ console.log('recorder check skipped (not macOS)');
23
+ process.exit(0);
24
+ }
25
+
26
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
27
+ const arch = process.arch === 'x64' ? 'x64' : 'arm64';
28
+ const binary = process.argv[2] ?? path.join(root, 'prebuilt', 'macos', arch, 'bridge');
29
+ if (!existsSync(binary)) {
30
+ console.error(`FAIL missing helper binary ${binary} — run \`npm run build:native\` first`);
31
+ process.exit(1);
32
+ }
33
+
34
+ const temp = mkdtempSync(path.join(os.tmpdir(), 'cu-rec-'));
35
+ const socketPath = path.join(temp, 'bridge.sock');
36
+ const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
37
+ const EVENT_KINDS = new Set(['app', 'window', 'text', 'click', 'key', 'system']);
38
+
39
+ /** 一条连接:按行收 JSON。 */
40
+ function connect() {
41
+ return new Promise((resolve, reject) => {
42
+ const socket = net.createConnection(socketPath);
43
+ const lines = [];
44
+ const waiters = [];
45
+ let buffer = '';
46
+ let closed = false;
47
+ socket.setEncoding('utf8');
48
+ socket.on('data', (chunk) => {
49
+ buffer += chunk;
50
+ let index;
51
+ while ((index = buffer.indexOf('\n')) >= 0) {
52
+ const line = buffer.slice(0, index);
53
+ buffer = buffer.slice(index + 1);
54
+ if (!line) continue;
55
+ lines.push(JSON.parse(line));
56
+ waiters.splice(0).forEach((wake) => wake());
57
+ }
58
+ });
59
+ socket.on('close', () => { closed = true; waiters.splice(0).forEach((wake) => wake()); });
60
+ socket.once('error', reject);
61
+ socket.once('connect', () => {
62
+ socket.removeListener('error', reject);
63
+ socket.on('error', () => {});
64
+ resolve({
65
+ socket,
66
+ lines,
67
+ get closed() { return closed; },
68
+ send(object) { socket.write(`${JSON.stringify(object)}\n`); },
69
+ async next(timeoutMs = 3000) {
70
+ const deadline = Date.now() + timeoutMs;
71
+ while (lines.length === 0) {
72
+ if (closed) throw new Error('connection closed before a reply');
73
+ if (Date.now() > deadline) throw new Error('timed out waiting for a line');
74
+ await new Promise((wake) => { waiters.push(wake); setTimeout(wake, 100); });
75
+ }
76
+ return lines.shift();
77
+ },
78
+ });
79
+ });
80
+ });
81
+ }
82
+
83
+ async function request(object) {
84
+ const connection = await connect();
85
+ try {
86
+ connection.send(object);
87
+ return await connection.next();
88
+ } finally {
89
+ connection.socket.destroy();
90
+ }
91
+ }
92
+
93
+ let child;
94
+ try {
95
+ child = spawn(binary, ['serve', '--socket', socketPath], { stdio: ['ignore', 'ignore', 'inherit'] });
96
+ let diagnostics;
97
+ for (let attempt = 0; attempt < 100 && !diagnostics; attempt++) {
98
+ if (child.exitCode !== null) throw new Error(`helper exited early (code ${child.exitCode})`);
99
+ try { diagnostics = await request({ id: 'diag-0', cmd: 'diagnostics' }); } catch { await pause(100); }
100
+ }
101
+ assert.equal(diagnostics?.ok, true, 'helper must answer diagnostics');
102
+ assert.equal(diagnostics.result.protocolVersion, 13, `protocol must be 13, got ${diagnostics.result.protocolVersion}`);
103
+ assert.equal(diagnostics.result.recorderSubscribers, 0, 'no subscribers at startup');
104
+ console.log('PASS helper answers diagnostics with protocol 13 and no subscribers');
105
+ // 无痕扫描计数(只有计数):真机探针据此判断「扫描不完整时仍按普通窗口记」要不要改。刚起的 helper 全是 0。
106
+ assert.equal(diagnostics.result.recorderPrivateScans, 0, 'recorderPrivateScans is a number, 0 at startup');
107
+ assert.equal(diagnostics.result.recorderPrivateScanIncomplete, 0, 'recorderPrivateScanIncomplete is a number, 0 at startup');
108
+ assert.deepEqual(diagnostics.result.recorderPrivateScanStops, { afterWebArea: 0, depthCap: 0, nodeCap: 0, deadline: 0 }, 'recorderPrivateScanStops lists every stop reason');
109
+ console.log('PASS diagnostics reports private-scan counters');
110
+
111
+ const subscription = await connect();
112
+ subscription.send({
113
+ id: 'rec-1',
114
+ cmd: 'recordSubscribe',
115
+ policy: { excludeBundleIds: ['com.example.never'], titleOnlyBundleIds: ['com.forsion.*'], excludeDomains: ['example.com'], text: true, clicks: true, keys: true },
116
+ });
117
+ const reply = await subscription.next();
118
+ assert.equal(reply.id, 'rec-1', 'reply echoes the request id');
119
+ let subscribed = false;
120
+ if (reply.ok) {
121
+ assert.equal(reply.result?.subscribed, true, 'ok reply says subscribed');
122
+ assert.equal(reply.result?.protocolVersion, 13, 'ok reply carries protocol 13');
123
+ assert.equal(typeof reply.result?.axTrusted, 'boolean', 'ok reply carries axTrusted');
124
+ subscribed = true;
125
+ console.log('PASS recordSubscribe replied ok:true subscribed (this process tree has an Accessibility grant)');
126
+ } else {
127
+ assert.equal(reply.error?.code, 'accessibility_denied', `denied reply must use accessibility_denied, got ${reply.error?.code}`);
128
+ assert.equal(typeof reply.error?.message, 'string', 'denied reply carries a message');
129
+ console.log('PASS recordSubscribe replied ok:false accessibility_denied (expected for an ungranted dev build)');
130
+ }
131
+
132
+ await pause(1000);
133
+ assert.equal(subscription.closed, false, 'the subscription connection stays open for 1s');
134
+ console.log('PASS connection stays open for 1s after the reply');
135
+
136
+ if (subscribed) {
137
+ const kinds = {};
138
+ for (const line of subscription.lines.splice(0)) {
139
+ assert.equal(typeof line.ev?.t, 'number', 'every streamed line is {"ev":{t,...}}');
140
+ assert.ok(EVENT_KINDS.has(line.ev.kind), `unknown event kind ${line.ev?.kind}`);
141
+ kinds[line.ev.kind] = (kinds[line.ev.kind] ?? 0) + 1;
142
+ }
143
+ console.log(`PASS streamed lines are well-formed events (kinds only: ${JSON.stringify(kinds)})`);
144
+ const during = await request({ id: 'diag-1', cmd: 'diagnostics' });
145
+ assert.equal(during.result.recorderSubscribers, 1, 'one subscriber while the connection is open');
146
+ subscription.socket.destroy();
147
+ let after;
148
+ for (let attempt = 0; attempt < 30; attempt++) {
149
+ after = await request({ id: 'diag-2', cmd: 'diagnostics' });
150
+ if (after.result.recorderSubscribers === 0) break;
151
+ await pause(100);
152
+ }
153
+ assert.equal(after.result.recorderSubscribers, 0, 'closing the client unsubscribes it');
154
+ console.log('PASS closing the client drops the subscriber back to 0');
155
+ } else {
156
+ assert.equal(subscription.lines.length, 0, 'a denied connection streams no events');
157
+ subscription.send({ id: 'diag-3', cmd: 'diagnostics' });
158
+ const same = await subscription.next();
159
+ assert.equal(same.id, 'diag-3', 'a denied connection keeps serving ordinary requests');
160
+ assert.equal(same.result.recorderSubscribers, 0, 'denied subscription registers nothing');
161
+ subscription.socket.destroy();
162
+ console.log('PASS denied connection stays a normal request connection with no subscriber');
163
+ }
164
+ } finally {
165
+ await request({ id: 'bye', cmd: 'shutdown' }).catch(() => {});
166
+ for (let attempt = 0; attempt < 30 && child && child.exitCode === null && child.signalCode === null; attempt++) await pause(100);
167
+ if (child && child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); // 只杀我们自己起的隔离实例,绝不碰用户的常驻 helper
168
+ rmSync(temp, { recursive: true, force: true });
169
+ }
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
  };
@@ -10,7 +10,7 @@ import type { PlatformDiagnostics } from "../types.ts";
10
10
  import { resolveMacosHelperAppPath } from "./helper-path.mjs";
11
11
 
12
12
  const COMMAND_TIMEOUT_MS = 15_000;
13
- const HELPER_PROTOCOL_VERSION = 12;
13
+ const HELPER_PROTOCOL_VERSION = 13;
14
14
  const HELPER_SETUP_TIMEOUT_MS = 60_000;
15
15
 
16
16
  export const HELPER_BUNDLE_ID = "com.forsion.tangu-computer-use";
@@ -171,7 +171,7 @@ var init_helper = __esm({
171
171
  init_coerce();
172
172
  init_helper_path();
173
173
  COMMAND_TIMEOUT_MS2 = 15e3;
174
- HELPER_PROTOCOL_VERSION = 12;
174
+ HELPER_PROTOCOL_VERSION = 13;
175
175
  HELPER_SETUP_TIMEOUT_MS = 6e4;
176
176
  HELPER_BUNDLE_ID = "com.forsion.tangu-computer-use";
177
177
  HELPER_APP_PATH = resolveMacosHelperAppPath();
@@ -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.5.8",
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
  }