cotutor 0.3.0 → 0.4.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.
Files changed (209) hide show
  1. package/LICENSE +9 -7
  2. package/NOTICE +3 -3
  3. package/README.md +65 -18
  4. package/avatars/chinese.webp +0 -0
  5. package/avatars/english.webp +0 -0
  6. package/avatars/math.webp +0 -0
  7. package/bin/cotutor.js +13 -3
  8. package/cards/photo/card.css +10 -3
  9. package/cards/photo/card.md +2 -2
  10. package/cards/tutor/card.css +13 -0
  11. package/cards/tutor/card.md +2 -1
  12. package/cards//346/235/277/344/271/246/346/200/216/344/271/210/345/206/231.md +1 -1
  13. package/dist/app/activity-eval.js +3 -2
  14. package/dist/app/cli/activities.d.ts +8 -1
  15. package/dist/app/cli/activities.js +42 -4
  16. package/dist/app/cli/cert.d.ts +11 -0
  17. package/dist/app/cli/cert.js +36 -9
  18. package/dist/app/cli/chalk/browser.d.ts +2 -2
  19. package/dist/app/cli/chalk/browser.js +22 -7
  20. package/dist/app/cli/chalk/main.js +3 -2
  21. package/dist/app/cli/doctor.d.ts +11 -5
  22. package/dist/app/cli/doctor.js +156 -181
  23. package/dist/app/cli/firewall.d.ts +24 -0
  24. package/dist/app/cli/firewall.js +79 -0
  25. package/dist/app/cli/init.js +8 -5
  26. package/dist/app/cli/main.js +379 -27
  27. package/dist/app/cli/migrate.d.ts +2 -11
  28. package/dist/app/cli/migrate.js +1 -59
  29. package/dist/app/cli/port-holder.d.ts +16 -0
  30. package/dist/app/cli/port-holder.js +34 -0
  31. package/dist/app/cli/port.d.ts +37 -0
  32. package/dist/app/cli/port.js +116 -0
  33. package/dist/app/cli/sandbox.d.ts +25 -0
  34. package/dist/app/cli/sandbox.js +166 -0
  35. package/dist/app/cli/serve-log.d.ts +13 -0
  36. package/dist/app/cli/serve-log.js +90 -0
  37. package/dist/app/cli/serve.d.ts +18 -4
  38. package/dist/app/cli/serve.js +78 -8
  39. package/dist/app/cli/service-windows.d.ts +50 -0
  40. package/dist/app/cli/service-windows.js +117 -0
  41. package/dist/app/cli/service.d.ts +152 -0
  42. package/dist/app/cli/service.js +443 -0
  43. package/dist/app/cli/skeleton.d.ts +1 -1
  44. package/dist/app/cli/skeleton.js +13 -13
  45. package/dist/app/cli/skills.d.ts +5 -1
  46. package/dist/app/cli/skills.js +14 -4
  47. package/dist/app/cli/tray-icons.d.ts +11 -0
  48. package/dist/app/cli/tray-icons.js +87 -0
  49. package/dist/app/cli/tray-ids.d.ts +6 -0
  50. package/dist/app/cli/tray-ids.js +6 -0
  51. package/dist/app/cli/tray-mac.d.ts +8 -0
  52. package/dist/app/cli/tray-mac.js +140 -0
  53. package/dist/app/cli/tray-run.d.ts +1 -0
  54. package/dist/app/cli/tray-run.js +42 -0
  55. package/dist/app/cli/tray-windows.d.ts +5 -0
  56. package/dist/app/cli/tray-windows.js +166 -0
  57. package/dist/app/cli/tray.d.ts +236 -0
  58. package/dist/app/cli/tray.js +670 -0
  59. package/dist/app/cli/usage.d.ts +1 -1
  60. package/dist/app/cli/usage.js +11 -6
  61. package/dist/app/dictation/route.js +3 -2
  62. package/dist/app/http/app.d.ts +5 -10
  63. package/dist/app/http/app.js +47 -98
  64. package/dist/app/http/avatars.d.ts +17 -0
  65. package/dist/app/http/avatars.js +35 -0
  66. package/dist/app/http/dev-tutor-files.js +1 -1
  67. package/dist/app/http/parent-home.d.ts +14 -0
  68. package/dist/app/http/parent-home.js +69 -6
  69. package/dist/app/http/parent-look.d.ts +2 -0
  70. package/dist/app/http/parent-look.js +151 -0
  71. package/dist/app/http/raw-view.js +3 -3
  72. package/dist/app/http/voice-preview.d.ts +4 -0
  73. package/dist/app/http/voice-preview.js +34 -0
  74. package/dist/app/machine/bailian.d.ts +0 -2
  75. package/dist/app/machine/bailian.js +5 -6
  76. package/dist/app/machine/update.d.ts +25 -0
  77. package/dist/app/machine/update.js +88 -0
  78. package/dist/app/mock/mock-live.js +1 -1
  79. package/dist/app/mock/mock.d.ts +4 -2
  80. package/dist/app/mock/mock.js +20 -12
  81. package/dist/app/replay.js +4 -4
  82. package/dist/app/skills/home-doc.js +1 -1
  83. package/dist/app/workspace.d.ts +9 -4
  84. package/dist/app/workspace.js +33 -10
  85. package/dist/app/ws/route.d.ts +8 -2
  86. package/dist/app/ws/route.js +7 -3
  87. package/dist/board/parse.d.ts +0 -2
  88. package/dist/board/parse.js +0 -6
  89. package/dist/board/presentation.d.ts +9 -15
  90. package/dist/board/presentation.js +8 -10
  91. package/dist/brain/api-tools.js +2 -1
  92. package/dist/brain/api.d.ts +12 -17
  93. package/dist/brain/api.js +16 -33
  94. package/dist/brain/index.d.ts +23 -71
  95. package/dist/brain/index.js +6 -69
  96. package/dist/brain/log.d.ts +5 -0
  97. package/dist/brain/log.js +22 -0
  98. package/dist/brain/runtime.d.ts +30 -0
  99. package/dist/brain/runtime.js +23 -0
  100. package/dist/brain/{cli/stream-json.d.ts → stream-json.d.ts} +2 -4
  101. package/dist/brain/{cli/stream-json.js → stream-json.js} +0 -4
  102. package/dist/brain/stream.d.ts +5 -5
  103. package/dist/brain/stream.js +7 -7
  104. package/dist/brain/transcript.d.ts +3 -3
  105. package/dist/brain/transcript.js +6 -6
  106. package/dist/brain/{cli/types.d.ts → types.d.ts} +3 -48
  107. package/dist/brain/types.js +14 -0
  108. package/dist/chalk/glyphs/skeleton.d.ts +3 -3
  109. package/dist/chalk/glyphs/skeleton.js +1 -1
  110. package/dist/lib/index.d.ts +1 -1
  111. package/dist/lib/index.js +1 -1
  112. package/dist/memory/diary.js +3 -3
  113. package/dist/realtime/bailian.d.ts +1 -1
  114. package/dist/realtime/bailian.js +3 -2
  115. package/dist/realtime/preview.js +1 -1
  116. package/dist/realtime/voices.d.ts +2 -0
  117. package/dist/realtime/voices.js +57 -57
  118. package/dist/runtime/activities.d.ts +13 -1
  119. package/dist/runtime/activities.js +28 -2
  120. package/dist/runtime/activity-state.js +2 -2
  121. package/dist/runtime/agent-file.d.ts +3 -3
  122. package/dist/runtime/agent-file.js +3 -3
  123. package/dist/runtime/context/tutor-rules.d.ts +2 -2
  124. package/dist/runtime/context/tutor-rules.js +3 -3
  125. package/dist/runtime/home/home.d.ts +12 -1
  126. package/dist/runtime/home/home.js +8 -1
  127. package/dist/runtime/home/store.d.ts +1 -1
  128. package/dist/runtime/home/store.js +36 -21
  129. package/dist/runtime/journal/mix.d.ts +15 -6
  130. package/dist/runtime/journal/mix.js +60 -19
  131. package/dist/runtime/journal/recorder.d.ts +58 -13
  132. package/dist/runtime/journal/recorder.js +106 -16
  133. package/dist/runtime/journal/store.d.ts +4 -4
  134. package/dist/runtime/journal/store.js +6 -6
  135. package/dist/runtime/kid-input.js +2 -3
  136. package/dist/runtime/live/call.d.ts +12 -0
  137. package/dist/runtime/live/call.js +51 -18
  138. package/dist/runtime/live/turns.js +1 -1
  139. package/dist/runtime/runner.d.ts +9 -55
  140. package/dist/runtime/runner.js +52 -173
  141. package/dist/scenes/paint/painter.js +4 -3
  142. package/dist/schema/config.d.ts +43 -22
  143. package/dist/schema/config.js +45 -47
  144. package/dist/schema/conversation.d.ts +6 -0
  145. package/dist/schema/home.d.ts +11 -1
  146. package/dist/schema/home.js +7 -2
  147. package/dist/schema/workspace.d.ts +27 -0
  148. package/dist/schema/workspace.js +28 -0
  149. package/dist/shared/default-port.d.ts +2 -0
  150. package/dist/shared/default-port.js +2 -0
  151. package/dist/shared/qianwen.d.ts +14 -10
  152. package/dist/shared/qianwen.js +26 -22
  153. package/dist/shared/site-url.d.ts +2 -0
  154. package/dist/shared/site-url.js +5 -0
  155. package/dist/shared/upgrade.d.ts +9 -0
  156. package/dist/shared/upgrade.js +23 -0
  157. package/dist/ui/dev/dev-page.js +25 -252
  158. package/dist/ui/dev/dev-theme.d.ts +1 -1
  159. package/dist/ui/dev/dev-theme.js +1 -1
  160. package/dist/ui/dev/voice-gallery-page.js +1 -1
  161. package/dist/ui/kid/board-mark.d.ts +2 -2
  162. package/dist/ui/kid/board-mark.js +2 -2
  163. package/dist/ui/kid/home-tiles.d.ts +14 -0
  164. package/dist/ui/kid/home-tiles.js +44 -0
  165. package/dist/ui/kid/kid-page.js +68 -103
  166. package/dist/ui/kid/voice-ui.d.ts +1 -1
  167. package/dist/ui/kid/voice-ui.js +1 -1
  168. package/dist/ui/kid/word-emoji.js +5 -3
  169. package/dist/ui/parent/parent-home-page.js +337 -7
  170. package/dist/ui/parent/parent-page.js +6 -2
  171. package/dist/ui/parent/parent-settings-page.d.ts +1 -0
  172. package/dist/ui/parent/parent-settings-page.js +133 -0
  173. package/dist/ui/{dev → parent}/qr-page.d.ts +2 -2
  174. package/dist/ui/{dev → parent}/qr-page.js +19 -14
  175. package/dist/ui/shared/brand.d.ts +3 -1
  176. package/dist/ui/shared/brand.js +3 -1
  177. package/grades/grade-2/README.md +1 -1
  178. package/grades/grade-2/VALIDATION.md +1 -1
  179. package/grades/grade-2/agents/chinese-tutor/activities//345/244/215/344/271/240.md +1 -1
  180. package/grades/grade-2/agents/chinese-tutor/activities//346/{211/271/346/224/271.md → 243/200/346/237/245/344/275/234/344/270/232.md} +4 -4
  181. package/grades/grade-2/agents/chinese-tutor/activities//346/{211/271/346/224/271. → 243/200/346/237/245/344/275/234/344/270/232.}/344/276/213/345/255/220.md +2 -2
  182. package/grades/grade-2/agents/chinese-tutor/activities//351/242/204/344/271/240.md +1 -1
  183. package/grades/grade-2/agents/english-tutor/activities//345/244/215/344/271/240.md +1 -1
  184. package/grades/grade-2/agents/english-tutor/activities//346/{211/271/346/224/271.md → 243/200/346/237/245/344/275/234/344/270/232.md} +4 -4
  185. package/grades/grade-2/agents/english-tutor/activities//346/{211/271/346/224/271. → 243/200/346/237/245/344/275/234/344/270/232.}/344/276/213/345/255/220.md +2 -2
  186. package/grades/grade-2/agents/english-tutor/activities//351/242/204/344/271/240.md +1 -1
  187. package/grades/grade-2/agents/math-tutor/activities//345/244/215/344/271/240.md +1 -1
  188. package/grades/grade-2/agents/math-tutor/activities//346/{211/271/346/224/271.md → 243/200/346/237/245/344/275/234/344/270/232.md} +4 -4
  189. package/grades/grade-2/agents/math-tutor/activities//346/{211/271/346/224/271. → 243/200/346/237/245/344/275/234/344/270/232.}/344/276/213/345/255/220.md +2 -2
  190. package/grades/grade-2/agents/math-tutor/activities//351/242/204/344/271/240.md +1 -1
  191. package/package.json +6 -2
  192. package/skills/cotutor-analyze/references//345/221/275/344/273/244/344/270/216/346/226/207/344/273/266.md +2 -2
  193. package/skills/cotutor-board/references/photo.md +2 -2
  194. package/skills/cotutor-home/SKILL.md +1 -0
  195. package/skills/cotutor-home/references/tutor.md +2 -1
  196. package/skills/cotutor-home/references//351/246/226/351/241/265/350/257/255/346/263/225.md +2 -1
  197. package/skills/cotutor-tune/references//345/255/227/346/256/265.md +6 -3
  198. package/skills/cotutor-tutor/SKILL.md +5 -13
  199. package/dist/brain/cli/adapters.d.ts +0 -60
  200. package/dist/brain/cli/adapters.js +0 -91
  201. package/dist/brain/cli/qwen.d.ts +0 -11
  202. package/dist/brain/cli/qwen.js +0 -141
  203. package/dist/brain/cli/run-plan.d.ts +0 -59
  204. package/dist/brain/cli/run-plan.js +0 -71
  205. package/dist/brain/cli/run.d.ts +0 -35
  206. package/dist/brain/cli/run.js +0 -256
  207. package/dist/brain/cli/types.js +0 -3
  208. package/dist/brain/cli/warm.d.ts +0 -53
  209. package/dist/brain/cli/warm.js +0 -103
@@ -0,0 +1,87 @@
1
+ /**
2
+ * 托盘图标的 PNG:现画,不往仓里塞图片文件(同 favicon.ts);几何都来自 logo.ts 的双 C,16、18 px 这种小尺寸用小号那档。
3
+ * 正常时只有 logo;有事在右边缺口放状态点,橙 = 要处理,红 = 没在跑(同时 logo 变淡,不只靠颜色区分)。
4
+ * macOS:logo 一张模板图(系统按菜单栏深浅着色),状态点一张模板图(叠在上面单独着色),18 pt @2x
5
+ * Windows:没有底色,white 给深色任务栏、ink 给浅色任务栏,每种状态各画几档尺寸,按系统要的尺寸挑;变淡后对比度要过 3:1
6
+ * 菜单第一行的小圆点两边共用。
7
+ */
8
+ import { LOGO_INK, LOGO_SMALL, logoHit } from "../../ui/shared/logo.js";
9
+ import { encodePng } from "../../ui/shared/png.js";
10
+ const rgb = (hex) => {
11
+ const v = parseInt(hex.slice(1), 16);
12
+ return [(v >> 16) & 0xff, (v >> 8) & 0xff, v & 0xff];
13
+ };
14
+ export const TRAY_DOT = { r: 14, cx: 85.5 };
15
+ /** 没在跑时 logo 的不透明度(#F3F3F3 上墨色对比度 3.3,#202020 上白色 5.8) */
16
+ export const TRAY_DIM = 0.55;
17
+ export const TRAY_WIN_SIZES = [16, 20, 24, 32, 40, 48];
18
+ const SS = 8;
19
+ /** at 返回这一点的颜色与不透明度,null = 透明 */
20
+ function draw(n, at) {
21
+ const px = Buffer.alloc(n * n * 4);
22
+ const unit = 100 / n;
23
+ for (let y = 0; y < n; y++) {
24
+ for (let x = 0; x < n; x++) {
25
+ let r = 0;
26
+ let g = 0;
27
+ let b = 0;
28
+ let a = 0;
29
+ for (let sy = 0; sy < SS; sy++) {
30
+ for (let sx = 0; sx < SS; sx++) {
31
+ const p = at((x + (sx + 0.5) / SS) * unit, (y + (sy + 0.5) / SS) * unit);
32
+ if (!p)
33
+ continue;
34
+ r += p.c[0] * p.a;
35
+ g += p.c[1] * p.a;
36
+ b += p.c[2] * p.a;
37
+ a += p.a;
38
+ }
39
+ }
40
+ if (a <= 0)
41
+ continue;
42
+ const off = (y * n + x) * 4;
43
+ px[off] = Math.round(r / a);
44
+ px[off + 1] = Math.round(g / a);
45
+ px[off + 2] = Math.round(b / a);
46
+ px[off + 3] = Math.round((255 * a) / (SS * SS));
47
+ }
48
+ }
49
+ return encodePng(n, px, 4);
50
+ }
51
+ const inDot = (u, v) => Math.hypot(u - TRAY_DOT.cx, v - 50) <= TRAY_DOT.r;
52
+ const BLACK = [0, 0, 0];
53
+ /** 菜单里的小圆点(10 pt @2x) */
54
+ const MENU_DOT = { ok: '#1F9D55', warn: '#E8830C', bad: '#D92D20' };
55
+ const menuDot = (hex) => draw(20, (u, v) => (Math.hypot(u - 50, v - 50) <= 42 ? { c: rgb(hex), a: 1 } : null));
56
+ /** 文件名 → 内容:macOS 那几张 */
57
+ export function macTrayIcons() {
58
+ const out = {
59
+ 'logo.png': draw(36, (u, v) => (logoHit(u, v, LOGO_SMALL) ? { c: BLACK, a: 1 } : null)),
60
+ 'dotmark.png': draw(36, (u, v) => (inDot(u, v) ? { c: BLACK, a: 1 } : null)),
61
+ };
62
+ for (const [s, c] of Object.entries(MENU_DOT))
63
+ out[`dot-${s}.png`] = menuDot(c);
64
+ return out;
65
+ }
66
+ const WIN_THEMES = {
67
+ white: { fg: [255, 255, 255], warn: rgb('#FFA938'), bad: rgb('#FF5A4F') },
68
+ ink: { fg: rgb(LOGO_INK), warn: rgb('#C96A00'), bad: rgb('#D92D20') },
69
+ };
70
+ /** 文件名 → 内容:Windows 那几张(<white|ink>-<ok|warn|bad>-<尺寸>.png、dot-<状态>.png) */
71
+ export function windowsTrayIcons() {
72
+ const out = {};
73
+ for (const [name, t] of Object.entries(WIN_THEMES)) {
74
+ for (const s of ['ok', 'warn', 'bad']) {
75
+ for (const n of TRAY_WIN_SIZES) {
76
+ out[`${name}-${s}-${n}.png`] = draw(n, (u, v) => {
77
+ if (s !== 'ok' && inDot(u, v))
78
+ return { c: t[s], a: 1 };
79
+ return logoHit(u, v, LOGO_SMALL) ? { c: t.fg, a: s === 'bad' ? TRAY_DIM : 1 } : null;
80
+ });
81
+ }
82
+ }
83
+ }
84
+ for (const [s, c] of Object.entries(MENU_DOT))
85
+ out[`dot-${s}.png`] = menuDot(c);
86
+ return out;
87
+ }
@@ -0,0 +1,6 @@
1
+ /** 托盘的几个名字。不和后台服务撞:服务的 Label 是 com.tarslab.cotutor.<名>、Run 项是 cotutor-<名>,workspace 叫 tray 也不会认错 */
2
+ export declare const TRAY_LABEL = "com.tarslab.cotutor-tray";
3
+ export declare const TRAY_RUN_VALUE = "cotutorTray";
4
+ export declare const TRAY_AUMID = "cotutor.tray";
5
+ /** 显示脚本发现没有桌面(ssh 进来的会话)时的退出码:node 不再起它 */
6
+ export declare const HELPER_NO_SESSION = 3;
@@ -0,0 +1,6 @@
1
+ /** 托盘的几个名字。不和后台服务撞:服务的 Label 是 com.tarslab.cotutor.<名>、Run 项是 cotutor-<名>,workspace 叫 tray 也不会认错 */
2
+ export const TRAY_LABEL = 'com.tarslab.cotutor-tray';
3
+ export const TRAY_RUN_VALUE = 'cotutorTray';
4
+ export const TRAY_AUMID = 'cotutor.tray';
5
+ /** 显示脚本发现没有桌面(ssh 进来的会话)时的退出码:node 不再起它 */
6
+ export const HELPER_NO_SESSION = 3;
@@ -0,0 +1,8 @@
1
+ /**
2
+ * macOS 菜单栏图标的显示脚本:osascript -l JavaScript 跑,系统自带,不用签名、不用装东西(tray.ts 每次起来写到托盘目录再跑)。
3
+ * 这里只管画和回报,不判断状态:从 stdin 收一行一条 JSON(show / notify / confirm / inspect / press / quit),从 stdout 回一行一条 JSON(ready / click / confirm / state)。
4
+ * stdin 断了就退:看护它的 node 没了,图标也不该留着。
5
+ * JXA 的坑(2026-11-06 在 macOS 27.2 上试的):$.NSAttributedString.alloc.initWithStringAttributes 不存在,要 NSMutableAttributedString.alloc.init 再填;枚举常量取不到值,写数字(NSImageScaleProportionallyUpOrDown = 3);返回整数的方法给的是字符串,比较前要 Number()。
6
+ * 状态点是叠在按钮上的一层,系统的模板图才能继续跟着菜单栏深浅变;试过不挡点击。
7
+ */
8
+ export declare const MAC_TRAY_SCRIPT: string;
@@ -0,0 +1,140 @@
1
+ /**
2
+ * macOS 菜单栏图标的显示脚本:osascript -l JavaScript 跑,系统自带,不用签名、不用装东西(tray.ts 每次起来写到托盘目录再跑)。
3
+ * 这里只管画和回报,不判断状态:从 stdin 收一行一条 JSON(show / notify / confirm / inspect / press / quit),从 stdout 回一行一条 JSON(ready / click / confirm / state)。
4
+ * stdin 断了就退:看护它的 node 没了,图标也不该留着。
5
+ * JXA 的坑(2026-11-06 在 macOS 27.2 上试的):$.NSAttributedString.alloc.initWithStringAttributes 不存在,要 NSMutableAttributedString.alloc.init 再填;枚举常量取不到值,写数字(NSImageScaleProportionallyUpOrDown = 3);返回整数的方法给的是字符串,比较前要 Number()。
6
+ * 状态点是叠在按钮上的一层,系统的模板图才能继续跟着菜单栏深浅变;试过不挡点击。
7
+ */
8
+ export const MAC_TRAY_SCRIPT = String.raw `ObjC.import('Cocoa');
9
+ const app = Application.currentApplication();
10
+ app.includeStandardAdditions = true;
11
+ const dir = $.NSProcessInfo.processInfo.environment.objectForKey('COTUTOR_TRAY_DIR').js;
12
+ const stdout = $.NSFileHandle.fileHandleWithStandardOutput;
13
+ const stdin = $.NSFileHandle.fileHandleWithStandardInput;
14
+ const emit = (o) => stdout.writeData($(JSON.stringify(o) + '\n').dataUsingEncoding($.NSUTF8StringEncoding));
15
+ let item, menu, tray, dotView, current = 'ok', pending = $.NSMutableData.alloc.init;
16
+ let ids = [];
17
+
18
+ const image = (name, size, template) => {
19
+ const i = $.NSImage.alloc.initWithContentsOfFile($(dir + '/icons/' + name));
20
+ i.setSize($.NSMakeSize(size, size));
21
+ if (template) i.setTemplate(true);
22
+ return i;
23
+ };
24
+ const styled = (text, font, color) => {
25
+ const t = $.NSMutableAttributedString.alloc.init;
26
+ t.replaceCharactersInRangeWithString($.NSMakeRange(0, 0), $(text));
27
+ t.setAttributesRange($.NSDictionary.dictionaryWithObjectsForKeys($([font, color]), $([$.NSFontAttributeName, $.NSForegroundColorAttributeName])), $.NSMakeRange(0, text.length));
28
+ return t;
29
+ };
30
+ function center() {
31
+ const b = item.button.bounds;
32
+ dotView.frame = $.NSMakeRect((b.size.width - 18) / 2, (b.size.height - 18) / 2, 18, 18);
33
+ }
34
+ function fill(entries) {
35
+ menu.removeAllItems;
36
+ ids = [];
37
+ for (const e of entries) {
38
+ if (e.kind === 'sep') { menu.addItem($.NSMenuItem.separatorItem); continue; }
39
+ const m = $.NSMenuItem.alloc.initWithTitleActionKeyEquivalent($(e.title), e.kind === 'action' ? 'clicked:' : null, $(''));
40
+ if (e.kind === 'action') {
41
+ m.target = tray;
42
+ m.enabled = true;
43
+ // 标号从 1 起:别的行缺省是 0,按标号找会找到它们
44
+ ids.push(e.id);
45
+ m.tag = ids.length;
46
+ if (e.primary) m.attributedTitle = styled(e.title, $.NSFont.boldSystemFontOfSize(13), $.NSColor.labelColor);
47
+ } else if (e.kind === 'info') {
48
+ m.enabled = false;
49
+ m.attributedTitle = styled(e.title, $.NSFont.boldSystemFontOfSize(13), $.NSColor.labelColor);
50
+ if (e.level) m.image = image('dot-' + e.level + '.png', 10, false);
51
+ } else {
52
+ m.enabled = false;
53
+ m.attributedTitle = styled(e.title, $.NSFont.systemFontOfSize(11), $.NSColor.secondaryLabelColor);
54
+ }
55
+ menu.addItem(m);
56
+ }
57
+ }
58
+ function show(m) {
59
+ current = m.icon;
60
+ dotView.setHidden(m.icon === 'ok');
61
+ dotView.contentTintColor = m.icon === 'warn' ? $.NSColor.systemOrangeColor : $.NSColor.systemRedColor;
62
+ item.button.appearsDisabled = m.icon === 'bad';
63
+ item.button.toolTip = $(m.tip);
64
+ fill(m.menu);
65
+ center();
66
+ }
67
+ function quit() {
68
+ $.NSStatusBar.systemStatusBar.removeStatusItem(item);
69
+ $.NSApp.terminate(null);
70
+ }
71
+ function confirm(m) {
72
+ const a = $.NSAlert.alloc.init;
73
+ a.messageText = $(m.title);
74
+ a.informativeText = $(m.body);
75
+ // 第一个按钮是回车默认的那个:默认不退出
76
+ a.addButtonWithTitle($(m.no));
77
+ a.addButtonWithTitle($(m.yes));
78
+ $.NSApp.activateIgnoringOtherApps(true);
79
+ // 桥接把整数返回成字符串("1001"),不转就永远不等
80
+ emit({ type: 'confirm', id: m.id, yes: Number(a.runModal) === 1001 });
81
+ }
82
+ function handle(m) {
83
+ if (m.type === 'show') show(m);
84
+ else if (m.type === 'notify') { try { app.displayNotification(m.body, { withTitle: m.title }); } catch (e) { emit({ type: 'error', message: String(e) }); } }
85
+ else if (m.type === 'confirm') confirm(m);
86
+ else if (m.type === 'quit') quit();
87
+ else if (m.type === 'inspect') {
88
+ const titles = [];
89
+ for (let i = 0; i < menu.numberOfItems; i++) titles.push(menu.itemAtIndex(i).title.js);
90
+ emit({ type: 'state', icon: current, dot: !dotView.isHidden, dim: item.button.appearsDisabled, visible: (item.button.window.occlusionState & 2) === 2, appearance: item.button.effectiveAppearance.name.js, menu: titles });
91
+ } else if (m.type === 'press') {
92
+ const n = ids.indexOf(m.id);
93
+ if (n >= 0) { const at = menu.indexOfItemWithTag(n + 1); if (at >= 0) menu.performActionForItemAtIndex(at); }
94
+ }
95
+ }
96
+ function received() {
97
+ const data = stdin.availableData;
98
+ if (Number(data.length) === 0) { quit(); return; }
99
+ pending.appendData(data);
100
+ const text = $.NSString.alloc.initWithDataEncoding(pending, $.NSUTF8StringEncoding);
101
+ // 一个汉字被切在两块之间时解不出来:等下一块
102
+ if (!text.isNil()) {
103
+ const lines = text.js.split('\n');
104
+ const rest = lines.pop();
105
+ pending = $.NSMutableData.alloc.init;
106
+ if (rest) pending.appendData($(rest).dataUsingEncoding($.NSUTF8StringEncoding));
107
+ for (const line of lines) {
108
+ if (!line) continue;
109
+ try { handle(JSON.parse(line)); } catch (e) { emit({ type: 'error', message: String(e) }); }
110
+ }
111
+ }
112
+ stdin.waitForDataInBackgroundAndNotify;
113
+ }
114
+ ObjC.registerSubclass({
115
+ name: 'CotutorTray',
116
+ methods: {
117
+ 'clicked:': { types: ['void', ['id']], implementation: function (sender) { emit({ type: 'click', id: ids[Number(sender.tag) - 1] }); } },
118
+ 'received:': { types: ['void', ['id']], implementation: function () { received(); } },
119
+ },
120
+ });
121
+ const nsapp = $.NSApplication.sharedApplication;
122
+ nsapp.setActivationPolicy($.NSApplicationActivationPolicyAccessory);
123
+ tray = $.CotutorTray.alloc.init;
124
+ item = $.NSStatusBar.systemStatusBar.statusItemWithLength(28);
125
+ item.button.image = image('logo.png', 18, true);
126
+ item.button.setAccessibilityLabel($('cotutor'));
127
+ dotView = $.NSImageView.alloc.initWithFrame($.NSMakeRect(5, 2, 18, 18));
128
+ dotView.image = image('dotmark.png', 18, true);
129
+ dotView.imageScaling = 3;
130
+ dotView.setHidden(true);
131
+ item.button.addSubview(dotView);
132
+ menu = $.NSMenu.alloc.init;
133
+ menu.autoenablesItems = false;
134
+ item.menu = menu;
135
+ center();
136
+ $.NSNotificationCenter.defaultCenter.addObserverSelectorNameObject(tray, 'received:', $.NSFileHandleDataAvailableNotification, stdin);
137
+ stdin.waitForDataInBackgroundAndNotify;
138
+ emit({ type: 'ready' });
139
+ nsapp.run;
140
+ `;
@@ -0,0 +1 @@
1
+ export declare function main(argv: string[]): Promise<void>;
@@ -0,0 +1,42 @@
1
+ /**
2
+ * cotutor tray run 的轻入口:bin/cotutor.js 直接到这里,不经 main.ts。
3
+ * 托盘进程一直开着,main.ts 把所有命令都加载进来要多占近一百 MB(2026-11-06 量的,源码直跑 140 MB 对 63 MB);这里和 tray.ts 引的东西都不带 zod。
4
+ */
5
+ import { mkdirSync, readFileSync } from 'node:fs';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { realHost } from "./service.js";
8
+ import { realTrayDeps, runTray, trayDir, trayNote, traySupported, writeTrayFiles } from "./tray.js";
9
+ const readVersion = () => JSON.parse(readFileSync(fileURLToPath(new URL('../../../package.json', import.meta.url)), 'utf8')).version;
10
+ let installed = readVersion();
11
+ /** 每轮重读:npm 升级时这个进程一直开着,只记起来那一刻的就会误报。装到一半读不到就用上次的 */
12
+ const version = () => {
13
+ try {
14
+ installed = readVersion();
15
+ }
16
+ catch { /* 下一轮再读 */ }
17
+ return installed;
18
+ };
19
+ export async function main(argv) {
20
+ const host = realHost();
21
+ if (!traySupported(host.platform)) {
22
+ process.stderr.write(`${host.platform} 上没做托盘图标\n`);
23
+ process.exitCode = 0;
24
+ return;
25
+ }
26
+ const at = argv.indexOf('--dir');
27
+ const dir = at >= 0 && argv[at + 1] ? argv[at + 1] : trayDir(host);
28
+ mkdirSync(dir, { recursive: true });
29
+ const note = trayNote(dir);
30
+ // 没接住的错误:记一笔再以非 0 退(macOS 上 launchd 隔 10 秒再起)
31
+ const fatal = (what) => (err) => {
32
+ note(`${what}:${err instanceof Error ? err.stack : String(err)}`);
33
+ process.exit(1);
34
+ };
35
+ process.on('uncaughtException', fatal('没接住的错误'));
36
+ process.on('unhandledRejection', fatal('没接住的 Promise 错误'));
37
+ const file = await writeTrayFiles(host.platform, dir);
38
+ note(`起来了,node ${process.versions.node},pid ${process.pid}`);
39
+ const code = await runTray(realTrayDeps(host, dir, file, version));
40
+ note(`退出 ${code}`);
41
+ process.exit(code);
42
+ }
@@ -0,0 +1,5 @@
1
+ export declare const WINDOWS_TRAY_SCRIPT: string;
2
+ /** 找这台电脑上这个用户的托盘进程(命令行里带 tray run 和这个用户自己的托盘目录) */
3
+ export declare function findTrayScript(dir: string): string;
4
+ /** 启动器:wscript 跑它,藏着窗口起 node(和后台服务同一个办法,见 service-windows.ts) */
5
+ export declare function trayLauncher(commandLine: string): string;
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Windows 托盘图标的显示脚本:Windows PowerShell + WinForms 的 NotifyIcon,系统自带,不用装东西(tray.ts 每次起来写到托盘目录再跑,存 UTF-8 带 BOM,不然中文按系统代码页读)。
3
+ * 这里只管画和回报,不判断状态:从 stdin 收一行一条 JSON(show / notify / confirm / inspect / press / quit),从 stdout 回一行一条 JSON(ready / click / confirm / state)。
4
+ * stdin 断了就退:看护它的 node 没了,图标也不该留着。没有桌面的会话(ssh 进来)里图标没人看得见,以 3 退,node 也不再起。
5
+ * 深浅自己看:每两秒读一次 SystemUsesLightTheme(任务栏跟它走,不是 AppsUseLightTheme;在深色任务栏上用白色 logo,浅色用墨色)。
6
+ * 通知走 WinRT 的 ToastNotifier + 注册的应用 ID,来源才写 cotutor;直接用 NotifyIcon 的气泡,来源是 Windows PowerShell(2026-11-06 build 26300 上都试过)。不行就退回气泡。
7
+ * 点通知没有动作:PowerShell 5.1 接不了 WinRT 的事件(Register-ObjectEvent 报 INVALID_REGISTRATION,脚本块直接接又在别的线程上没有 runspace),所以文案同 macOS,请家长点图标。
8
+ */
9
+ import { TRAY_AUMID } from "./tray-ids.js";
10
+ export const WINDOWS_TRAY_SCRIPT = String.raw `Add-Type -AssemblyName System.Windows.Forms, System.Drawing
11
+ $dir = $env:COTUTOR_TRAY_DIR
12
+ $aumid = '${TRAY_AUMID}'
13
+ if ((Get-Process -Id $PID).SessionId -eq 0) { exit 3 }
14
+ $utf8 = New-Object System.Text.UTF8Encoding($false)
15
+ # stderr 进 tray.log:不设的话是系统代码页,中文 Windows 上读出来是乱码
16
+ try { [Console]::OutputEncoding = $utf8 } catch { }
17
+ $stdin = New-Object System.IO.StreamReader([Console]::OpenStandardInput(), $utf8)
18
+ $stdout = [Console]::OpenStandardOutput()
19
+ function Emit($o) {
20
+ $b = $utf8.GetBytes((ConvertTo-Json -Compress -InputObject $o) + [char]10)
21
+ $stdout.Write($b, 0, $b.Length)
22
+ $stdout.Flush()
23
+ }
24
+ $want = [System.Windows.Forms.SystemInformation]::SmallIconSize.Width
25
+ $size = (@(16, 20, 24, 32, 40, 48) | Where-Object { $_ -ge $want } | Select-Object -First 1)
26
+ if (-not $size) { $size = 48 }
27
+ $icons = @{}; $dots = @{}
28
+ foreach ($t in 'white', 'ink') { foreach ($s in 'ok', 'warn', 'bad') { $icons["$t-$s"] = [System.Drawing.Icon]::FromHandle(([System.Drawing.Bitmap]::new("$dir\icons\$t-$s-$size.png")).GetHicon()) } }
29
+ foreach ($s in 'ok', 'warn', 'bad') { $dots[$s] = [System.Drawing.Bitmap]::new("$dir\icons\dot-$s.png") }
30
+ $themeKey = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize'
31
+ function ThemeNow { try { if ((Get-ItemProperty $themeKey).SystemUsesLightTheme -eq 1) { 'ink' } else { 'white' } } catch { 'white' } }
32
+ $amKey = "HKCU:\Software\Classes\AppUserModelId\$aumid"
33
+ $script:toastReady = $false
34
+ $script:theme = ThemeNow
35
+ function RegisterToast {
36
+ New-Item $amKey -Force | Out-Null
37
+ Set-ItemProperty $amKey -Name DisplayName -Value 'cotutor'
38
+ Set-ItemProperty $amKey -Name IconUri -Value "$dir\icons\$($script:theme)-ok-48.png"
39
+ }
40
+ try {
41
+ RegisterToast
42
+ [void][Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime]
43
+ [void][Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime]
44
+ $script:toastReady = $true
45
+ } catch { Emit @{ type = 'error'; message = "toast: $_" } }
46
+ $ni = New-Object System.Windows.Forms.NotifyIcon
47
+ $script:level = 'ok'
48
+ $ni.Icon = $icons["$($script:theme)-ok"]; $ni.Text = 'cotutor'; $ni.Visible = $true
49
+ $menu = New-Object System.Windows.Forms.ContextMenuStrip
50
+ $menu.ShowImageMargin = $false
51
+ $menu.Font = New-Object System.Drawing.Font('Microsoft YaHei UI', 9)
52
+ $bold = New-Object System.Drawing.Font('Microsoft YaHei UI', 9, [System.Drawing.FontStyle]::Bold)
53
+ $tiny = New-Object System.Drawing.Font('Microsoft YaHei UI', 8)
54
+ $grey = [System.Drawing.Color]::FromArgb(90, 90, 90)
55
+ $ni.ContextMenuStrip = $menu
56
+ $showMenu = [System.Windows.Forms.NotifyIcon].GetMethod('ShowContextMenu', [Reflection.BindingFlags]'Instance,NonPublic')
57
+ $ni.add_MouseUp({ param($s, $e) if ($e.Button -eq 'Left') { try { $showMenu.Invoke($ni, $null) | Out-Null } catch { } } })
58
+ function Quit {
59
+ $ni.Visible = $false
60
+ $ni.Dispose()
61
+ [System.Windows.Forms.Application]::Exit()
62
+ }
63
+ function Fill($entries) {
64
+ while ($menu.Items.Count) { $x = $menu.Items[0]; $menu.Items.RemoveAt(0); $x.Dispose() }
65
+ foreach ($e in $entries) {
66
+ if ($e.kind -eq 'sep') { [void]$menu.Items.Add((New-Object System.Windows.Forms.ToolStripSeparator)); continue }
67
+ if ($e.kind -eq 'action') {
68
+ $i = New-Object System.Windows.Forms.ToolStripMenuItem $e.title
69
+ $i.Tag = $e.id
70
+ if ($e.primary) { $i.Font = $bold }
71
+ $i.add_Click({ param($s, $a) Emit @{ type = 'click'; id = $s.Tag } })
72
+ } elseif ($e.kind -eq 'info') {
73
+ $i = New-Object System.Windows.Forms.ToolStripLabel $e.title
74
+ $i.Font = $bold
75
+ if ($e.level) { $i.Image = $dots[$e.level]; $i.ImageAlign = 'MiddleLeft' }
76
+ $i.TextAlign = 'MiddleLeft'
77
+ } else {
78
+ $i = New-Object System.Windows.Forms.ToolStripLabel $e.title
79
+ $i.Font = $tiny
80
+ $i.ForeColor = $grey
81
+ $i.TextAlign = 'MiddleLeft'
82
+ }
83
+ [void]$menu.Items.Add($i)
84
+ }
85
+ }
86
+ function Show($m) {
87
+ $script:level = $m.icon
88
+ $ni.Icon = $icons["$($script:theme)-$($m.icon)"]
89
+ $t = [string]$m.tip
90
+ if ($t.Length -gt 63) { $t = $t.Substring(0, 63) }
91
+ $ni.Text = $t
92
+ Fill $m.menu
93
+ }
94
+ function Notify($title, $body) {
95
+ if ($script:toastReady) {
96
+ try {
97
+ $esc = { param($v) [System.Security.SecurityElement]::Escape([string]$v) }
98
+ $xml = New-Object Windows.Data.Xml.Dom.XmlDocument
99
+ $xml.LoadXml("<toast><visual><binding template='ToastGeneric'><text>$(& $esc $title)</text><text>$(& $esc $body)</text></binding></visual></toast>")
100
+ $toast = [Windows.UI.Notifications.ToastNotification]::new($xml)
101
+ [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($aumid).Show($toast)
102
+ return
103
+ } catch { Emit @{ type = 'error'; message = "toast: $_" } }
104
+ }
105
+ $ni.ShowBalloonTip(8000, $title, $body, [System.Windows.Forms.ToolTipIcon]::None)
106
+ }
107
+ function Confirm($m) {
108
+ $owner = New-Object System.Windows.Forms.Form
109
+ $owner.TopMost = $true
110
+ $answer = [System.Windows.Forms.MessageBox]::Show($owner, $m.body, $m.title, 'YesNo', 'Warning', 'Button2')
111
+ $owner.Dispose()
112
+ Emit @{ type = 'confirm'; id = $m.id; yes = ($answer -eq 'Yes') }
113
+ }
114
+ function Handle($m) {
115
+ switch ($m.type) {
116
+ 'show' { Show $m }
117
+ 'notify' { Notify $m.title $m.body }
118
+ 'confirm' { Confirm $m }
119
+ 'quit' { Quit }
120
+ 'inspect' {
121
+ $titles = @(); foreach ($i in $menu.Items) { $titles += [string]$i.Text }
122
+ $p = Get-Process -Id $PID
123
+ Emit @{ type = 'state'; icon = $script:level; theme = $script:theme; size = $size; privateMb = [int]($p.PrivateMemorySize64 / 1MB); workingMb = [int]($p.WorkingSet64 / 1MB); menu = $titles }
124
+ }
125
+ 'press' { foreach ($i in $menu.Items) { if ($i.Tag -eq $m.id) { $i.PerformClick(); break } } }
126
+ }
127
+ }
128
+ $script:reading = $stdin.ReadLineAsync()
129
+ $script:themeAt = Get-Date
130
+ $timer = New-Object System.Windows.Forms.Timer
131
+ $timer.Interval = 200
132
+ $timer.add_Tick({
133
+ while ($script:reading.IsCompleted) {
134
+ $line = $script:reading.Result
135
+ if ($null -eq $line) { $timer.Stop(); Quit; return }
136
+ $script:reading = $stdin.ReadLineAsync()
137
+ if ($line) { try { Handle (ConvertFrom-Json $line) } catch { Emit @{ type = 'error'; message = [string]$_ } } }
138
+ }
139
+ if (((Get-Date) - $script:themeAt).TotalSeconds -ge 2) {
140
+ $script:themeAt = Get-Date
141
+ $t = ThemeNow
142
+ if ($t -ne $script:theme) {
143
+ $script:theme = $t
144
+ $ni.Icon = $icons["$t-$($script:level)"]
145
+ if ($script:toastReady) { try { RegisterToast } catch { } }
146
+ }
147
+ }
148
+ })
149
+ $timer.Start()
150
+ Emit @{ type = 'ready' }
151
+ [System.Windows.Forms.Application]::Run()
152
+ `;
153
+ /** 找这台电脑上这个用户的托盘进程(命令行里带 tray run 和这个用户自己的托盘目录) */
154
+ export function findTrayScript(dir) {
155
+ return [
156
+ `$dir = '${dir.replaceAll("'", "''")}'`,
157
+ "$found = @(Get-CimInstance Win32_Process -Filter \"Name='node.exe'\" | Where-Object { $c = $_.CommandLine; $c -and $c.Contains($dir) -and $c.Contains(' tray run ') })",
158
+ 'ConvertTo-Json -Compress -InputObject @($found | ForEach-Object { @{ pid = $_.ProcessId; keeper = $true } })',
159
+ ].join('\n');
160
+ }
161
+ /** 启动器:wscript 跑它,藏着窗口起 node(和后台服务同一个办法,见 service-windows.ts) */
162
+ export function trayLauncher(commandLine) {
163
+ return `// cotutor tray on 写的;改了会被下次盖掉。关掉:cotutor tray off
164
+ new ActiveXObject("WScript.Shell").Run(${JSON.stringify(commandLine)}, 0, false);
165
+ `;
166
+ }