@forsion/tangu-computer-use 0.6.0 → 0.6.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.
Files changed (35) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/UPSTREAM.md +3 -1
  3. package/icon.png +0 -0
  4. package/main.js +15 -0
  5. package/manifest.json +3 -3
  6. package/native/macos/bridge.swift +171 -7
  7. package/native/windows/bridge-rs/Cargo.lock +1 -0
  8. package/native/windows/bridge-rs/Cargo.toml +11 -0
  9. package/native/windows/bridge-rs/src/agent_marker.rs +97 -0
  10. package/native/windows/bridge-rs/src/lib.rs +4 -0
  11. package/native/windows/bridge-rs/src/main.rs +32 -0
  12. package/native/windows/bridge-rs/src/recorder.rs +1865 -0
  13. package/native/windows/bridge-rs/src/recorder_logic.rs +851 -0
  14. package/native/windows/bridge-rs/tests/recorder_logic_tests.rs +314 -0
  15. package/package.json +3 -2
  16. package/prebuilt/linux/x64/linux-bridge +0 -0
  17. package/prebuilt/macos/arm64/bridge +0 -0
  18. package/prebuilt/macos/arm64/tangu-computer-use.app.json +4 -4
  19. package/prebuilt/macos/arm64/tangu-computer-use.app.zip +0 -0
  20. package/prebuilt/macos/x64/bridge +0 -0
  21. package/prebuilt/macos/x64/tangu-computer-use.app.json +4 -4
  22. package/prebuilt/macos/x64/tangu-computer-use.app.zip +0 -0
  23. package/prebuilt/windows/windows-bridge.exe +0 -0
  24. package/scripts/exit-listeners.check.mjs +61 -0
  25. package/scripts/no-foreground.check.mjs +14 -1
  26. package/scripts/probe-recorder-windows.mjs +356 -0
  27. package/skills/computer-use/SKILL.md +4 -0
  28. package/src/foregroundNote.ts +16 -0
  29. package/src/index.ts +17 -5
  30. package/src/tools.ts +4 -3
  31. package/src/vendor/platform/macos/helper.ts +1 -1
  32. package/tangu-plugins/computer-use/dist/foregroundNote.js +10 -1
  33. package/tangu-plugins/computer-use/dist/index.js +25 -11
  34. package/tangu-plugins/computer-use/tangu-plugin.json +1 -1
  35. package/types/tangu-agent.d.ts +60 -1
@@ -0,0 +1,314 @@
1
+ //! 电脑历史采集器纯逻辑(src/recorder_logic.rs)的单测 —— 与 macOS activity_recorder_tests.swift 同一批用例,
2
+ //! 任何平台都能跑(`cargo test`)。
3
+
4
+ use serde_json::{json, Value};
5
+ use windows_bridge::recorder_logic::*;
6
+
7
+ fn ctx(name: &str, bundle: &str, title: Option<&str>, url: Option<&str>) -> Context {
8
+ Context {
9
+ name: name.into(),
10
+ bundle_id: bundle.into(),
11
+ title: title.map(Into::into),
12
+ url: url.map(Into::into),
13
+ ..Context::default()
14
+ }
15
+ }
16
+
17
+ fn excluded(body: &Option<serde_json::Map<String, Value>>) -> bool {
18
+ body.as_ref()
19
+ .and_then(|b| b.get("app"))
20
+ .and_then(|a| a.get("excluded"))
21
+ .and_then(Value::as_bool)
22
+ == Some(true)
23
+ }
24
+
25
+ fn field<'a>(body: &'a Option<serde_json::Map<String, Value>>, key: &str) -> Option<&'a str> {
26
+ body.as_ref().and_then(|b| b.get(key)).and_then(Value::as_str)
27
+ }
28
+
29
+ #[test]
30
+ fn text_diff_matches_macos() {
31
+ assert_eq!(text_diff("abc", "abc"), None);
32
+ assert_eq!(text_diff("", "hello"), Some(TextDiff { inserted: "hello".into(), deleted: 0 }));
33
+ assert_eq!(text_diff("hello world", "hello brave world"), Some(TextDiff { inserted: "brave ".into(), deleted: 0 }));
34
+ assert_eq!(text_diff("hello world", "hello"), Some(TextDiff { inserted: "".into(), deleted: 6 }));
35
+ assert_eq!(text_diff("cat", "cot"), Some(TextDiff { inserted: "o".into(), deleted: 1 }));
36
+ assert_eq!(text_diff("aa", "aaa"), Some(TextDiff { inserted: "a".into(), deleted: 0 }));
37
+ assert_eq!(text_diff("你好", "你们好"), Some(TextDiff { inserted: "们".into(), deleted: 0 }));
38
+ }
39
+
40
+ #[test]
41
+ fn settle_text_matches_macos() {
42
+ assert_eq!(settle_text("", Some("hello"), ""), Some(TextDiff { inserted: "hello".into(), deleted: 0 }), "cleared after send");
43
+ assert_eq!(settle_text("draft", Some("draft more"), "draft more!"), Some(TextDiff { inserted: " more!".into(), deleted: 0 }));
44
+ assert_eq!(settle_text("abc", None, "ab"), Some(TextDiff { inserted: "".into(), deleted: 1 }));
45
+ assert_eq!(settle_text("x", Some("x"), "x"), None);
46
+ }
47
+
48
+ #[test]
49
+ fn text_fields_cap_and_deletions() {
50
+ let long = text_fields(&TextDiff { inserted: "x".repeat(900), deleted: 3 });
51
+ assert_eq!(long["text"].as_str().unwrap().chars().count(), limit::TEXT);
52
+ assert_eq!(long["truncated"], json!(true));
53
+ assert_eq!(long["deleted"], json!(3));
54
+ let deletion = text_fields(&TextDiff { inserted: "".into(), deleted: 12 });
55
+ assert!(deletion.get("text").is_none());
56
+ assert_eq!(deletion["deleted"], json!(12));
57
+ }
58
+
59
+ #[test]
60
+ fn strings_are_collapsed_and_capped() {
61
+ assert_eq!(truncate("abc", 5), ("abc".into(), false));
62
+ assert_eq!(truncate("abcdef", 3), ("abc".into(), true));
63
+ assert_eq!(truncate("你好世界", 2), ("你好".into(), true));
64
+ assert_eq!(label(" Send\n message "), Some("Send message".into()));
65
+ assert_eq!(label(" "), None);
66
+ assert_eq!(label(&"x".repeat(200)).unwrap().len(), limit::LABEL);
67
+ assert_eq!(title(&"t".repeat(300)).unwrap().len(), limit::TITLE);
68
+ }
69
+
70
+ #[test]
71
+ fn private_markers() {
72
+ assert!(has_private_marker("Example — Mozilla Firefox Private Browsing"));
73
+ assert!(has_private_marker("New Tab - Google Chrome (Incognito)"));
74
+ assert!(has_private_marker("[InPrivate] Bing - Microsoft Edge"));
75
+ assert!(has_private_marker("新标签页 - 无痕模式"));
76
+ assert!(!has_private_marker("Pull requests · Private repo"));
77
+ assert!(!has_private_marker("Inbox (3) - Gmail"));
78
+ }
79
+
80
+ #[test]
81
+ fn private_verdicts_fail_closed() {
82
+ assert!(title_marks_private("MSEdge.exe") && title_marks_private("firefox.exe"));
83
+ assert!(!title_marks_private("chrome.exe"), "Chrome incognito titles carry no marker on Windows");
84
+ assert_eq!(private_verdict(false, true, false), PrivateVerdict::Private, "a toolbar hint wins even mid-walk");
85
+ assert_eq!(private_verdict(true, true, true), PrivateVerdict::Private);
86
+ assert_eq!(private_verdict(true, false, false), PrivateVerdict::Normal, "title-trusted browsers need no complete walk");
87
+ assert_eq!(private_verdict(false, false, true), PrivateVerdict::Normal, "a complete walk without a hint proves normal");
88
+ assert_eq!(private_verdict(false, false, false), PrivateVerdict::Unknown, "an incomplete walk proves nothing");
89
+ }
90
+
91
+ #[test]
92
+ fn urls_are_sanitized() {
93
+ assert_eq!(sanitize_url("https://example.com/path?token=abc#frag").as_deref(), Some("https://example.com/path"));
94
+ assert_eq!(sanitize_url("https://user:pw@example.com/a").as_deref(), Some("https://example.com/a"));
95
+ assert_eq!(sanitize_url("example.com/docs").as_deref(), Some("https://example.com/docs"));
96
+ assert_eq!(sanitize_url("github.com").as_deref(), Some("https://github.com"));
97
+ assert_eq!(sanitize_url("HTTP://Example.com:8080/a?b").as_deref(), Some("http://Example.com:8080/a"));
98
+ assert_eq!(sanitize_url("how to cook rice"), None);
99
+ assert_eq!(sanitize_url("githu"), None);
100
+ assert_eq!(sanitize_url("file:///C:/Users/me/secret.pdf"), None);
101
+ assert_eq!(sanitize_url("chrome://newtab"), None);
102
+ assert_eq!(sanitize_url("edge://settings"), None);
103
+ assert_eq!(sanitize_url(&format!("https://example.com/{}", "a".repeat(400))).unwrap().chars().count(), limit::URL);
104
+ assert_eq!(url_host("https://Docs.Example.com/x").as_deref(), Some("docs.example.com"));
105
+ assert_eq!(url_host("https://user@Example.com:8443/x").as_deref(), Some("example.com"));
106
+ assert_eq!(url_host("http://[::1]:3000/").as_deref(), Some("::1"));
107
+ }
108
+
109
+ #[test]
110
+ fn address_bar_values() {
111
+ assert_eq!(address_bar_url(""), (None, true), "new tab page: resolved, no site");
112
+ assert_eq!(address_bar_url("github.com/forsion"), (Some("https://github.com/forsion".into()), true));
113
+ assert_eq!(address_bar_url("chrome://settings/privacy"), (None, true));
114
+ assert_eq!(address_bar_url("edge://newtab"), (None, true));
115
+ assert_eq!(address_bar_url("about:blank"), (None, true));
116
+ assert_eq!(address_bar_url("mybank"), (None, false), "half-typed text is unresolved");
117
+ assert_eq!(address_bar_url("how to cook rice"), (None, false));
118
+ assert_eq!(address_bar_url("view-source:https://x.com"), (None, false), "wrapping schemes are not internal pages");
119
+ }
120
+
121
+ #[test]
122
+ fn address_field_detection() {
123
+ assert!(is_address_field("Address and search bar", "OmniboxViewViews", ""));
124
+ assert!(is_address_field("", "OmniboxViewViews", ""));
125
+ assert!(is_address_field("Search with Google or enter address", "", "urlbar-input"));
126
+ assert!(is_address_field("地址和搜索栏", "", ""));
127
+ assert!(!is_address_field("Message", "", ""));
128
+ }
129
+
130
+ #[test]
131
+ fn domains_and_bundles() {
132
+ assert_eq!(normalize_domain("https://www.Example.com/path").as_deref(), Some("www.example.com"));
133
+ assert_eq!(normalize_domain("*.example.com").as_deref(), Some("example.com"));
134
+ assert_eq!(normalize_domain(".example.com.").as_deref(), Some("example.com"));
135
+ assert_eq!(normalize_domain("example.com:8443").as_deref(), Some("example.com"));
136
+ assert_eq!(normalize_domain(" "), None);
137
+ assert!(host_matches("example.com", "example.com"));
138
+ assert!(host_matches("mail.example.com", "example.com"));
139
+ assert!(host_matches("MAIL.example.com.", "example.com"));
140
+ assert!(!host_matches("badexample.com", "example.com"));
141
+ assert!(!host_matches("example.com.evil.net", "example.com"));
142
+ assert!(bundle_matches("com.forsion.desktop", "com.forsion.*"));
143
+ assert!(!bundle_matches("com.forsionx.desktop", "com.forsion.*"));
144
+ assert!(bundle_matches("Chrome.EXE", "chrome.exe"));
145
+ assert!(!bundle_matches("chrome.exe.bak", "chrome.exe"));
146
+ assert!(is_hard_excluded("KeePassXC.exe"));
147
+ assert!(is_hard_excluded("1Password.exe"));
148
+ assert!(!is_hard_excluded("notepad.exe"));
149
+ assert!(is_browser("MSEdge.exe") && is_browser("firefox.exe") && !is_browser("thunderbird.exe"));
150
+ assert!(is_ignored("SearchHost.exe", "") && is_ignored("explorer.exe", "Shell_TrayWnd"));
151
+ assert!(!is_ignored("explorer.exe", "CabinetWClass"));
152
+ }
153
+
154
+ #[test]
155
+ fn controls() {
156
+ assert_eq!(text_mode(CT_EDIT, false, Some(true), None), Some(TextMode::Value));
157
+ assert_eq!(text_mode(CT_EDIT, true, Some(true), None), None, "password fields are never text");
158
+ assert_eq!(text_mode(CT_EDIT, false, Some(false), Some(true)), None, "read-only value means not editable");
159
+ assert_eq!(text_mode(CT_DOCUMENT, false, None, Some(true)), Some(TextMode::Text), "rich text documents");
160
+ assert_eq!(text_mode(CT_DOCUMENT, false, None, Some(false)), None, "a read-only web page is not a field");
161
+ assert_eq!(text_mode(CT_GROUP, false, None, Some(true)), Some(TextMode::Text), "web contenteditable");
162
+ assert_eq!(text_mode(CT_BUTTON, false, Some(true), None), None, "buttons with a value are not text");
163
+ assert_eq!(text_mode(CT_COMBOBOX, false, None, Some(true)), None);
164
+ assert_eq!(text_mode(CT_BUTTON, false, None, None), None);
165
+ assert!(click_label_allowed(CT_BUTTON) && click_label_allowed(CT_TABITEM) && click_label_allowed(CT_HYPERLINK));
166
+ assert!(!click_label_allowed(CT_EDIT), "text fields never carry a label");
167
+ assert!(!click_label_allowed(CT_DOCUMENT));
168
+ assert_eq!(control_type_name(CT_MENUITEM), "MenuItem");
169
+ assert_eq!(control_type_name(CT_RADIOBUTTON), "RadioButton");
170
+ assert_eq!(control_type_name(1), "Unknown");
171
+ }
172
+
173
+ #[test]
174
+ fn shortcuts() {
175
+ assert_eq!(key_combo(true, false, true, false, "P").as_deref(), Some("Ctrl+Shift+P"));
176
+ assert_eq!(key_combo(false, false, true, true, "S").as_deref(), Some("Win+Shift+S"));
177
+ assert_eq!(key_combo(false, true, true, false, "A"), None, "no Ctrl or Win means no shortcut");
178
+ assert_eq!(key_name(0x41, None).as_deref(), Some("A"));
179
+ assert_eq!(key_name(0x35, None).as_deref(), Some("5"));
180
+ assert_eq!(key_name(0x0D, None).as_deref(), Some("Enter"));
181
+ assert_eq!(key_name(0x70, None).as_deref(), Some("F1"));
182
+ assert_eq!(key_name(0x7B, None).as_deref(), Some("F12"));
183
+ assert_eq!(key_name(0xBF, Some('/')).as_deref(), Some("/"));
184
+ assert_eq!(key_name(0xBA, Some('\u{3}')), None, "control characters are not key names");
185
+ assert_eq!(key_name(0xBA, None), None);
186
+ }
187
+
188
+ #[test]
189
+ fn event_bodies() {
190
+ let open = Policy::default();
191
+ let chrome = ctx("Google Chrome", "chrome.exe", Some("Docs"), Some("https://docs.example.com/a"));
192
+ let full = event_body(Kind::Window, Some(&chrome), &open);
193
+ assert_eq!(field(&full, "title"), Some("Docs"));
194
+ assert_eq!(field(&full, "url"), Some("https://docs.example.com/a"));
195
+
196
+ let domain_off = Policy::from_json(Some(&json!({ "excludeDomains": ["example.com"] })));
197
+ let switched = event_body(Kind::App, Some(&chrome), &domain_off);
198
+ assert!(excluded(&switched) && field(&switched, "title").is_none() && field(&switched, "url").is_none());
199
+ assert!(event_body(Kind::Text, Some(&chrome), &domain_off).is_none());
200
+ assert!(event_body(Kind::Click, Some(&chrome), &domain_off).is_none());
201
+ let marker = event_body(Kind::Window, Some(&chrome), &domain_off);
202
+ assert!(excluded(&marker) && field(&marker, "title").is_none());
203
+
204
+ let app_off = Policy::from_json(Some(&json!({ "excludeBundleIds": ["Chrome.exe"] })));
205
+ let app_switch = event_body(Kind::App, Some(&chrome), &app_off);
206
+ assert!(excluded(&app_switch) && field(&app_switch, "title").is_none());
207
+ assert!(event_body(Kind::Window, Some(&chrome), &app_off).is_none());
208
+ assert!(event_body(Kind::Key, Some(&chrome), &app_off).is_none());
209
+
210
+ let title_only = Policy::from_json(Some(&json!({ "titleOnlyBundleIds": ["windowsterminal.exe", "com.forsion.*"] })));
211
+ let terminal = ctx("Windows Terminal", "windowsterminal.exe", Some("pwsh"), None);
212
+ assert_eq!(field(&event_body(Kind::Window, Some(&terminal), &title_only), "title"), Some("pwsh"));
213
+ assert!(event_body(Kind::Text, Some(&terminal), &title_only).is_none());
214
+
215
+ let mut incognito = ctx("Google Chrome", "chrome.exe", None, None);
216
+ incognito.is_private = true;
217
+ let private_app = event_body(Kind::App, Some(&incognito), &open);
218
+ assert!(private_app.is_some() && field(&private_app, "title").is_none() && !excluded(&private_app));
219
+ assert!(event_body(Kind::Text, Some(&incognito), &open).is_none());
220
+ assert!(event_body(Kind::Window, Some(&incognito), &open).is_none());
221
+
222
+ let mut vault = ctx("KeePassXC", "keepassxc.exe", None, None);
223
+ vault.hard_excluded = true;
224
+ assert!(excluded(&event_body(Kind::App, Some(&vault), &open)), "hard exclusion beats an open policy");
225
+ assert!(event_body(Kind::Window, Some(&vault), &open).is_none());
226
+
227
+ let no_text = Policy::from_json(Some(&json!({ "text": false, "clicks": true, "keys": false })));
228
+ assert!(event_body(Kind::Text, Some(&chrome), &no_text).is_none());
229
+ assert!(event_body(Kind::Key, Some(&chrome), &no_text).is_none());
230
+ assert!(event_body(Kind::Click, Some(&chrome), &no_text).is_some());
231
+ assert_eq!(field(&event_body(Kind::System, None, &open), "kind"), Some("system"));
232
+
233
+ let garbage = Policy::from_json(Some(&json!({ "excludeBundleIds": "not-a-list", "text": "yes" })));
234
+ assert!(garbage.exclude_bundle_ids.is_empty() && garbage.text);
235
+ assert_eq!(Policy::from_json(Some(&json!({ "excludeDomains": ["Example.com"] }))).exclude_domains, vec!["example.com"]);
236
+
237
+ let mut unknown = ctx("Google Chrome", "chrome.exe", Some("Chase — Accounts"), None);
238
+ unknown.url_unknown = true;
239
+ let fail_closed = event_body(Kind::Window, Some(&unknown), &domain_off);
240
+ assert!(excluded(&fail_closed) && field(&fail_closed, "title").is_none(), "unknown URL fails closed when sites are excluded");
241
+ assert_eq!(field(&event_body(Kind::Window, Some(&unknown), &open), "title"), Some("Chase — Accounts"));
242
+ }
243
+
244
+ #[test]
245
+ fn private_context_delivery() {
246
+ let docs = ctx("Google Chrome", "chrome.exe", Some("Docs"), Some("https://docs.example.com/a"));
247
+ let mail = ctx("Google Chrome", "chrome.exe", Some("Inbox"), Some("https://mail.example.com/"));
248
+ let mut incognito = ctx("Google Chrome", "chrome.exe", None, None);
249
+ incognito.is_private = true;
250
+ let open = Policy::default();
251
+ let mut state = DeliveryState::default();
252
+ let mut deliver = |kind, c: &Context, dedupe| context_delivery(kind, Some(c), &open, &mut state, dedupe);
253
+ assert_eq!(field(&deliver(Kind::App, &docs, false), "title"), Some("Docs"));
254
+ assert!(deliver(Kind::Window, &docs, true).is_none(), "identical window event is deduped");
255
+ assert!(deliver(Kind::Window, &incognito, true).is_none(), "entering private sends nothing");
256
+ let back = deliver(Kind::Window, &docs, true);
257
+ assert_eq!(field(&back, "title"), Some("Docs"), "returning to the same normal window is sent again");
258
+ assert_eq!(back.as_ref().unwrap()["resumed"], json!(true), "and carries the resumed marker");
259
+ assert!(deliver(Kind::Window, &docs, true).is_none());
260
+ let _ = deliver(Kind::Window, &incognito, true);
261
+ assert!(deliver(Kind::App, &incognito, true).is_none(), "re-activation while private sends no second switch");
262
+ assert_eq!(field(&deliver(Kind::Window, &mail, true), "title"), Some("Inbox"));
263
+ }
264
+
265
+ #[test]
266
+ fn resumed_marker_is_per_policy_and_once() {
267
+ let docs = ctx("Google Chrome", "chrome.exe", Some("Docs"), Some("https://docs.example.com/a"));
268
+ let bank = ctx("Google Chrome", "chrome.exe", Some("Accounts"), Some("https://mybank.com/accounts"));
269
+ let site_off = Policy::from_json(Some(&json!({ "excludeDomains": ["mybank.com"] })));
270
+ let mut state = DeliveryState::default();
271
+ assert!(context_delivery(Kind::App, Some(&docs), &site_off, &mut state, false).unwrap().get("resumed").is_none());
272
+ let marker = context_delivery(Kind::Window, Some(&bank), &site_off, &mut state, true);
273
+ assert!(excluded(&marker) && marker.as_ref().unwrap().get("resumed").is_none(), "the excluded marker itself never carries resumed");
274
+ let back = context_delivery(Kind::Window, Some(&docs), &site_off, &mut state, true).unwrap();
275
+ assert_eq!(back["resumed"], json!(true));
276
+ let again = context_delivery(Kind::Window, Some(&bank), &site_off, &mut state, true);
277
+ assert!(excluded(&again));
278
+ // 另一个不排除这个站点的订阅者:同一串情境没有断点
279
+ let mut open_state = DeliveryState::default();
280
+ let open = Policy::default();
281
+ let _ = context_delivery(Kind::App, Some(&docs), &open, &mut open_state, false);
282
+ let _ = context_delivery(Kind::Window, Some(&bank), &open, &mut open_state, true);
283
+ assert!(context_delivery(Kind::Window, Some(&docs), &open, &mut open_state, true).unwrap().get("resumed").is_none());
284
+ }
285
+
286
+ #[test]
287
+ fn pending_actions() {
288
+ assert_eq!(pending_on_refresh(false, false, false), PendingAction::Drop);
289
+ assert_eq!(pending_on_refresh(true, true, false), PendingAction::Flush);
290
+ assert_eq!(pending_on_refresh(true, false, true), PendingAction::Flush);
291
+ assert_eq!(pending_on_refresh(true, false, false), PendingAction::Keep);
292
+ }
293
+
294
+ #[test]
295
+ fn pipe_names() {
296
+ assert!(pipe_name_valid(r"\\.\pipe\tangu-computer-use-recorder-1a2b3c4d-0123456789ab"));
297
+ assert!(!pipe_name_valid(r"\\server\pipe\x"));
298
+ assert!(!pipe_name_valid(r"\\.\pipe\"));
299
+ assert!(!pipe_name_valid(r"\\.\pipe\a\b"));
300
+ assert!(!pipe_name_valid(r"C:\temp\x"));
301
+ }
302
+
303
+ #[test]
304
+ fn context_keys_match_desktop() {
305
+ let open = Policy::default();
306
+ let chrome = ctx("Google Chrome", "chrome.exe", Some("Docs"), Some("https://docs.example.com/a"));
307
+ let a = context_key(&event_body(Kind::App, Some(&chrome), &open).unwrap());
308
+ let b = context_key(&event_body(Kind::Window, Some(&chrome), &open).unwrap());
309
+ let mut retitled = chrome.clone();
310
+ retitled.title = Some("Other".into());
311
+ assert_eq!(a, b);
312
+ assert_ne!(a, context_key(&event_body(Kind::Window, Some(&retitled), &open).unwrap()));
313
+ assert_eq!(a, "chrome.exe\u{1F}\u{1F}Docs\u{1F}https://docs.example.com/a");
314
+ }
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.2",
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
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.2",
4
+ "archiveSha256": "d6c815f307a2f3119ba14b4821b7e972449c7eff4e056ad39cce12381f8f3061",
5
5
  "files": {
6
- "Contents/MacOS/bridge": "1fac72ce2c0491c6191a877bc7ec373d17c0bc371d3ffe67cd27b11739eb94ef",
7
- "Contents/Info.plist": "69122c51686853486a2453f8ac3ade5167f4a05b8614f235186aadbe1431b398",
6
+ "Contents/MacOS/bridge": "bb497e3f4a3d03141f2fb14323111fc2c1a27dd3f526c1170a3ebb1bd0d9a431",
7
+ "Contents/Info.plist": "b09a226bd57436b8e6e1ca6447c590820f46222b87b1a96084660a28f81c2cd7",
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.2",
4
+ "archiveSha256": "3dba34fdc2ae8c7ac9870f50c11c04c6c2080d73b148c3dfbeeac160cf4d49a9",
5
5
  "files": {
6
- "Contents/MacOS/bridge": "8c09d6c17e70f329c7958cff4db15c1308bc97e73b42844f33c31de0ebf48318",
7
- "Contents/Info.plist": "69122c51686853486a2453f8ac3ade5167f4a05b8614f235186aadbe1431b398",
6
+ "Contents/MacOS/bridge": "2477f7f95065592cfa60c11928b5622837a66bfe5f2ee540f88417e774769874",
7
+ "Contents/Info.plist": "b09a226bd57436b8e6e1ca6447c590820f46222b87b1a96084660a28f81c2cd7",
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
+ }
@@ -20,7 +20,7 @@ const execFileP = promisify(execFile);
20
20
  const here = path.dirname(fileURLToPath(import.meta.url));
21
21
 
22
22
  async function unitTests() {
23
- const { foregroundNote } = await import(path.join(here, '..', 'tangu-plugins', 'computer-use', 'dist', 'foregroundNote.js'));
23
+ const { foregroundNote, selfWindowNote } = await import(path.join(here, '..', 'tangu-plugins', 'computer-use', 'dist', 'foregroundNote.js'));
24
24
  const has = (s) => s.includes('[foreground]');
25
25
 
26
26
  // 无信号 → 空串
@@ -47,6 +47,19 @@ async function unitTests() {
47
47
  assert.ok(withReason.includes('foreground_required'), 'reason surfaced');
48
48
 
49
49
  console.log('✓ unit: foregroundNote 判据 9 项全过');
50
+
51
+ // 自家窗口提示(Forsion 反馈 6a239e58):目标 pid = 拉起引擎的桌面应用 → 提示改用界面命令;别的 App 不提示。
52
+ const own = (s) => s.includes('[forsion]');
53
+ assert.ok(own(selfWindowNote({ target: { pid: 80732 } }, 80732)), 'observe/act on the host app → note');
54
+ assert.ok(own(selfWindowNote({ windows: [{ pid: 501 }, { pid: 80732 }] }, 80732)), 'find_roots listing a host window → note');
55
+ assert.ok(selfWindowNote({ target: { pid: 80732 } }, 80732).includes('run_ui_command'), 'note names the UI tools');
56
+ assert.equal(selfWindowNote({ target: { pid: 501 } }, 80732), '', 'another app → empty');
57
+ assert.equal(selfWindowNote({ windows: [{ pid: 501 }] }, 80732), '', 'find_roots without host windows → empty');
58
+ assert.equal(selfWindowNote({ windows: [{ pid: 0 }] }, 0), '', 'browser pages carry pid 0; no host pid → empty');
59
+ assert.equal(selfWindowNote({ target: { pid: 1 } }, 1), '', 'orphaned engine (ppid 1) → empty');
60
+ assert.equal(selfWindowNote(undefined, 80732), '', 'no details → empty');
61
+ assert.equal(selfWindowNote({ tool: 'read_text' }, 80732), '', 'details without a target → empty');
62
+ console.log('✓ unit: selfWindowNote 判据 9 项全过');
50
63
  }
51
64
 
52
65
  async function frontmostBundleId() {