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
@@ -6,19 +6,18 @@
6
6
  import { isRealtimeVoice } from "../../realtime/voices.js";
7
7
  import { dashscopeAuth } from "../../realtime/bailian.js";
8
8
  import { TTS_MODEL, ttsTarget } from "../../media/tts.js";
9
- import { adapterFor, parserOf, prepareCli, processEnv, runtimeIssue } from "../../brain/index.js";
10
- import { execFile } from 'node:child_process';
9
+ import { compatibleLine, isPlanKey, tokenPlanKey } from "../../shared/qianwen.js";
10
+ import { runtimeIssue } from "../../brain/index.js";
11
+ import { homedir } from 'node:os';
11
12
  import { access, lstat, readFile, readdir, stat } from 'node:fs/promises';
12
- import { constants as fsConstants } from 'node:fs';
13
- import { basename, dirname, join, relative, resolve, sep } from 'node:path';
14
- import { promisify } from 'node:util';
15
- import { boardPreloaded, planRun, runtimeUses } from "../../brain/cli/run-plan.js";
16
- import { BOARD_GUIDE_PATH, boardGuideBody, takesTutorRules } from "../../runtime/context/tutor-rules.js";
13
+ import { constants as fsConstants, existsSync } from 'node:fs';
14
+ import { dirname, join, relative, resolve, sep } from 'node:path';
15
+ import { takesTutorRules } from "../../runtime/context/tutor-rules.js";
17
16
  import { parseAgentFile } from "../../runtime/agent-file.js";
18
17
  import { localDate } from "../../shared/time.js";
19
18
  import { MIN_CONTRAST, contrastWithWhite } from "../../ui/shared/tutor-color.js";
20
19
  import { memoryPath, missingEntry, pickNotes } from "../../memory/vault-notes.js";
21
- import { SKILLS_DIR, isApiRuntime, listTutors, mouthVoice, resolvePolicy, tutorFilePath } from "../../schema/index.js";
20
+ import { SKILLS_DIR, cardVoice, isApiRuntime, listTutors, mouthVoice, resolvePolicy, tutorFilePath } from "../../schema/index.js";
22
21
  import { kindsFor, tutorCardKinds } from "../../board/cards/index.js";
23
22
  import { scanVault } from "../../memory/vault.js";
24
23
  import { parseArtifactEvents } from "../../runtime/journal/ledger.js";
@@ -26,15 +25,23 @@ import { parseTimetable } from "../../memory/timetable.js";
26
25
  import { configGapsOf, legacyLayout } from "./migrate.js";
27
26
  import { httpsFiles } from "./serve.js";
28
27
  import { readPin } from "../http/access.js";
28
+ import { BUILD } from "../build.js";
29
+ import { isNewer, upgradeSteps } from "../../shared/upgrade.js";
29
30
  import { gradeOfSemester } from "../../memory/grade.js";
30
31
  import { DIRS } from "./skeleton.js";
31
32
  import { tutorStatuses, readManifest } from "./tutors.js";
32
- import { tokenText } from "../../shared/tokens.js";
33
33
  import { workspaceGradePack } from "./grades.js";
34
34
  import { CONFIG_FILE, ConfigError, USER_CONFIG, UsageError, assembleWorkspace, expandPath, parseConfig, readJson, redactHome, resolveRoot, } from "../workspace.js";
35
- const execFileP = promisify(execFile);
36
- /** doctor --live 发给老师的那句 */
37
- const LIVE_PROMPT = '只回一个字:好';
35
+ /** 每条不过的要么能跑(cmd)要么要人来(human),没有第三种:新加的检查项忘了写 cmd,也只是交给人,不会让 agent 瞎猜 */
36
+ function finish(report) {
37
+ for (const c of report.checks) {
38
+ if (c.ok)
39
+ delete c.cmd;
40
+ else if (!c.cmd)
41
+ c.human = true;
42
+ }
43
+ return report;
44
+ }
38
45
  async function statOrNull(p) {
39
46
  return stat(p).catch(() => null);
40
47
  }
@@ -46,113 +53,19 @@ async function evictedCount(dir) {
46
53
  return 0;
47
54
  }
48
55
  }
49
- /** 运行时的 CLI 名(run[0] 的 basename) */
50
- export function runtimeCli(run) {
51
- return basename(run[0] ?? '');
52
- }
53
- /** 已知的环境坑,从 CLI 的输出里认出来给修复命令(错误信息即修复指南) */
54
- export function explainLlmFailure(text, runtime) {
55
- const bin = runtimeCli(runtime);
56
- if (/does not support this model|version [\d.]+ or newer is required/i.test(text)) {
57
- return `这版 ${bin} 不认模型:检查 cotutor.json 的运行时 run / resume 中 -m 后的模型名,或运行 ${bin} update 升级`;
58
- }
59
- if (/403|request not allowed/i.test(text))
60
- return `API 拒了(403):检查 ${bin} 使用的千问 key、端点与模型权限`;
61
- if (/not logged in|login|authentication|401|unauthorized/i.test(text))
62
- return `${bin} 的 key 无效:检查 QWENCLOUD_TOKEN_PLAN_API_KEY 或 DASHSCOPE_API_KEY,再跑 cotutor doctor`;
63
- if (/ENOENT|command not found/i.test(text))
64
- return `PATH 里没有 ${bin}`;
65
- return `看上面的原文;不认识的错先在终端手跑一次同样的命令`;
66
- }
67
56
  /**
68
- * --live:真起一次老师(运行时的 run 模板,第一位开着的老师,只回一个字),把 API 层的坑(模型不认、没登录)摆到明面。
69
- * 花一分钱级别的费用,所以缺省不跑。配音同理:老师配了 voice 就合成一句。
57
+ * --live:老师用的是 API 运行时,不在这里真起一轮(真跑一轮用 cotutor send);配音照真合成一句(老师配了 voice 的),花一分钱级别,所以缺省不跑。
70
58
  */
71
59
  async function probeLive(ws, push, env) {
72
- const { spawn } = await import('node:child_process');
73
60
  const { listTutors } = await import("../../schema/index.js");
74
61
  const { synthesize } = await import("../../media/tts.js");
75
- const { parseTranscript } = await import("../../brain/transcript.js");
76
- const { parseAgentFile } = await import("../../runtime/agent-file.js");
77
62
  const { tmpdir } = await import('node:os');
78
63
  const { mkdtemp, rm } = await import('node:fs/promises');
79
64
  const tutors = listTutors(ws.config).filter((t) => t.enabled);
80
- const first = tutors[0];
81
- if (!first) {
82
- push({ name: 'live.agent', ok: false, required: false, detail: '没有开着的老师,没法探', fix: 'cotutor.json 里至少开一位' });
83
- return;
84
- }
85
65
  const runtimeName = ws.config.runtimes.default;
86
66
  const runtime = ws.config.runtimes[runtimeName];
87
- if (!runtime || typeof runtime === 'string')
88
- return;
89
- if (isApiRuntime(runtime)) {
90
- push({ name: `live.${runtimeName}`, ok: true, required: false, detail: `API 运行时(百炼 ${runtime.model}),不起 CLI;真跑一轮用 cotutor send` });
91
- return;
92
- }
93
- if (runtimeIssue(runtime))
94
- return; // 静态体检已报退役,不启动旧 CLI
95
- let agentBody;
96
- try {
97
- agentBody = parseAgentFile(await readFile(tutorFilePath(ws.dirs.agents, first.name), 'utf8')).body;
98
- }
99
- catch {
100
- /* 上面 tutor.* 已报 */
101
- }
102
- let boardBody;
103
- try {
104
- boardBody = boardGuideBody(await readFile(join(ws.root, BOARD_GUIDE_PATH), 'utf8'));
105
- }
106
- catch {
107
- /* skills.* 已报 */
108
- }
109
- const systemBody = agentBody !== undefined ? (boardBody && takesTutorRules(first.name) ? `${agentBody}\n\n${boardBody}` : agentBody) : undefined;
110
- // 和真跑同一条规划:命令行、stdin 那行、环境变量都由这个运行时的 CLI 适配器定
111
- const plan = planRun(ws.config, { session: null }, { agent: first.name, prompt: LIVE_PROMPT, agentBody, systemBody, boardFile: join(ws.root, BOARD_GUIDE_PATH), runtime: runtimeName, root: ws.root, readDirs: [...new Set([ws.root, ws.paths.vault])] });
112
- const { argv } = plan;
113
- const parse = parserOf(plan.cli);
114
- await prepareCli(plan.cli, ws.root);
115
- const cwd = join(ws.dirs.agents, first.name);
116
- const run = await new Promise((resolveRun) => {
117
- let out = '';
118
- let err = '';
119
- const child = spawn(argv[0], argv.slice(1), { cwd, env: processEnv(plan, { ...env, COTUTOR_WORKSPACE: ws.root }), stdio: [plan.stdin ? 'pipe' : 'ignore', 'pipe', 'pipe'] });
120
- const timer = setTimeout(() => child.kill(), 120_000);
121
- // 消息走 stdin 的运行时:写一条,看到收尾就关 stdin
122
- if (plan.stdin) {
123
- child.stdin?.on('error', () => { });
124
- child.stdin?.write(plan.stdin);
125
- }
126
- let rest = '';
127
- child.stdout?.on('data', (d) => {
128
- out += d.toString();
129
- rest += d.toString();
130
- const lines = rest.split('\n');
131
- rest = lines.pop() ?? '';
132
- if (plan.stdin && lines.some((l) => parse(l).some((e) => e.kind === 'result')))
133
- child.stdin?.end();
134
- });
135
- child.stderr?.on('data', (d) => (err += d.toString()));
136
- child.once('error', (e) => {
137
- clearTimeout(timer);
138
- resolveRun({ out, err, code: null, spawnErr: e.message });
139
- });
140
- child.once('close', (code) => {
141
- clearTimeout(timer);
142
- resolveRun({ out, err, code });
143
- });
144
- });
145
- const t = parseTranscript(run.out, parse);
146
- const said = t.items.filter((i) => i.kind === 'text' && !i.sub).map((i) => i.text).join(' ');
147
- const ok = Boolean(t.final?.ok) && !run.spawnErr;
148
- const raw = run.spawnErr ?? (t.final ? `${t.final.reason ?? ''} ${said}`.trim() : `没有 result 事件(exit ${run.code}):${run.err.trim().split('\n').slice(-3).join(' / ')}`);
149
- push({
150
- name: `live.${runtimeName}`,
151
- ok,
152
- required: false,
153
- detail: ok ? `${first.display} 回了「${(t.final?.text ?? '').slice(0, 20)}」${t.final?.inTokens !== undefined ? ` · ${tokenText({ in: t.final.inTokens, out: t.final.outTokens ?? 0 })}` : ''}` : `起不来或没答上:${raw.slice(0, 300)}`,
154
- fix: ok ? undefined : explainLlmFailure(raw, runtime.run),
155
- });
67
+ if (runtime && typeof runtime !== 'string' && isApiRuntime(runtime))
68
+ push({ name: `live.${runtimeName}`, ok: true, required: false, detail: `API 运行时(百炼 ${runtime.model}),不起老师;真跑一轮用 cotutor send` });
156
69
  const voiced = tutors.find((x) => x.voice);
157
70
  if (!voiced) {
158
71
  push({ name: 'live.tts', ok: true, required: false, detail: '没有老师配 voice,不探配音(点读、单词卡没有声音)' });
@@ -163,7 +76,7 @@ async function probeLive(ws, push, env) {
163
76
  const r = await synthesize({ text: '你好', voice: voiced.voice }, { cacheDir: dir, env });
164
77
  const size = r.file ? ((await stat(r.file).catch(() => null))?.size ?? 0) : 0;
165
78
  await rm(dir, { recursive: true, force: true });
166
- push({ name: 'live.tts', ok: Boolean(r.file), required: false, detail: r.file ? `${voiced.display} 的音色 ${voiced.voice} 能合成(${size} 字节)` : `${voiced.display} 的音色 ${voiced.voice} 没合成:${r.error}`, fix: r.file ? undefined : '家长端「首页」里填百炼的 key 与业务空间 id;音色到工作台音色页「卡的配音」重挑' });
79
+ push({ name: 'live.tts', ok: Boolean(r.file), required: false, detail: r.file ? `${voiced.display} 的音色 ${voiced.voice} 能合成(${size} 字节)` : `${voiced.display} 的音色 ${voiced.voice} 没合成:${r.error}`, fix: r.file ? undefined : '家长端「首页」里填百炼的 key 与业务空间 id;音色到家长端首页的「样子与声音」重挑' });
167
80
  }
168
81
  export async function doctorWorkspace(override, opts = {}) {
169
82
  const probeEnv = opts.probeEnv ?? true;
@@ -188,9 +101,14 @@ export async function doctorWorkspace(override, opts = {}) {
188
101
  detail: err.message,
189
102
  fix: err instanceof ConfigError ? `修复 ${redactHome(err.file)} 的 JSON,或删掉它` : 'cotutor init <slug>,或用 --workspace / COTUTOR_WORKSPACE 指定',
190
103
  });
191
- return { ok: false, root: null, source: null, checks };
104
+ return finish({ ok: false, root: null, source: null, checks });
192
105
  }
193
106
  push({ name: 'workspace', ok: true, required: true, detail: `${redactHome(root)}(来源:${source})` });
107
+ // cmd 要能照原样跑:体检时用 --workspace 指的,修的时候也得指
108
+ // 路径写 $HOME/…,和别的字段一样:用户名不传给 agent 的厂商;redactDeep 只换开头,管不到命令中间的路径;sh 与 PowerShell 都展开
109
+ const quoted = `"${redactHome(root)}"`;
110
+ const flag = source === 'flag' ? ` --workspace ${quoted}` : '';
111
+ const upgrade = `cotutor upgrade${flag}`;
194
112
  // ---- cotutor.json ----
195
113
  let ws = null;
196
114
  const configFile = join(root, CONFIG_FILE);
@@ -216,12 +134,20 @@ export async function doctorWorkspace(override, opts = {}) {
216
134
  push({ name: CONFIG_FILE, ok: false, required: true, detail: err.message, fix: '按上面逐条修;政策文件机器不重建,改坏了靠 git 回退' });
217
135
  }
218
136
  let teachingPack = null;
137
+ // 补缺的 init:slug 从配置来;年级照已装的写(init 不切换年级);建在缺省位置的不用写 --dir
138
+ const initCmd = () => {
139
+ if (!ws)
140
+ return undefined;
141
+ const slug = ws.config.kid.slug;
142
+ const grade = teachingPack ? ` --grade ${teachingPack.grade}` : '';
143
+ return `cotutor init ${slug}${grade}${root === join(homedir(), 'cotutor', slug) ? '' : ` --dir ${quoted}`}`;
144
+ };
219
145
  try {
220
146
  const manifest = await readManifest(root);
221
147
  teachingPack = await workspaceGradePack(root);
222
148
  push({ name: 'teachingPack', ok: true, required: true, detail: `${teachingPack.grade} 年级教学包 · ${redactHome(teachingPack.dir)}` });
223
149
  if (!manifest.teachingPack)
224
- push({ name: 'teachingPack.origin', ok: false, required: false, detail: '旧版出厂内容按二年级来源读取,尚未补记;孩子档案不变', fix: 'cotutor upgrade 补记来源,保留自定义文件与原 hash' });
150
+ push({ name: 'teachingPack.origin', ok: false, required: false, detail: '旧版出厂内容按二年级来源读取,尚未补记;孩子档案不变', fix: 'cotutor upgrade 补记来源,保留自定义文件与原 hash', cmd: upgrade });
225
151
  }
226
152
  catch (err) {
227
153
  push({ name: 'teachingPack', ok: false, required: true, detail: err.message, fix: '检查 .cotutor/shipped.json 的 teachingPack 与本包 grades/;恢复原来源记录或安装包含该年级的版本,不要改成其他年级绕过' });
@@ -235,6 +161,7 @@ export async function doctorWorkspace(override, opts = {}) {
235
161
  required: false,
236
162
  detail: gaps.length ? `${gaps.length} 项要改:${gaps.map((g) => g.detail).join(';')}` : 'cotutor.json 有出厂模板里的全部老师、运行时与旗标,也没有不再用的段',
237
163
  fix: gaps.length ? 'cotutor upgrade --config --dry-run 先看,再 cotutor upgrade --config 改(只加缺的、删不再用的出厂段,你改过的值不动)' : undefined,
164
+ cmd: gaps.length ? `cotutor upgrade --config${flag}` : undefined,
238
165
  });
239
166
  }
240
167
  // ---- 骨架 ----
@@ -248,17 +175,18 @@ export async function doctorWorkspace(override, opts = {}) {
248
175
  required: true,
249
176
  detail: missing.length ? `缺 ${missing.join(' ')}` : [...DIRS].map((d) => `${d}/`).join(' '),
250
177
  fix: missing.length ? `cotutor init <slug> --dir ${redactHome(root)}(幂等补缺)` : undefined,
178
+ cmd: missing.length ? initCmd() : undefined,
251
179
  });
252
180
  // 老布局(2026-10-30 之前):老师文件在 .claude/agents/、技能在 .claude/skills/,现在都读不到了
253
181
  const legacy = await legacyLayout(root);
254
182
  if (legacy.length)
255
- push({ name: 'layout', ok: false, required: true, detail: `${legacy.join(' ')} 是老位置:老师文件现在在 agents/<老师>.md,技能在 ${SKILLS_DIR}/`, fix: 'cotutor upgrade(原样挪过去,不改内容;新位置已有同名的不挪、会点名)' });
183
+ push({ name: 'layout', ok: false, required: true, detail: `${legacy.join(' ')} 是老位置:老师文件现在在 agents/<老师>.md,技能在 ${SKILLS_DIR}/`, fix: 'cotutor upgrade(原样挪过去,不改内容;新位置已有同名的不挪、会点名)', cmd: upgrade });
256
184
  // 家规(根的 QWEN.md)2026-09-15 起不出厂:有就是家长自建的共同规矩,提一句;没有不缺
257
185
  const rulesThere = (await Promise.all(['QWEN.md'].map(async (f) => ((await statOrNull(join(root, f))) ? f : null)))).filter(Boolean);
258
186
  push({ name: 'rules', ok: true, required: false, detail: rulesThere.length ? `家长自建的家规 ${rulesThere.join(' ')} 在,所有老师每轮都读` : '没有根目录的 QWEN.md(不需要:老师要知道的都在老师文件与技能里)' });
259
187
  if (ws) {
260
188
  // ---- 老师:定义文件(拷贝)+ 名字一致 + 出厂 / 自定义状态 + 老师目录 ----
261
- // 老师文件只有 agents/<name>.md 这一份:应用读正文递进系统提示({agentBody} / {systemBody})
189
+ // 老师文件只有 agents/<name>.md 这一份:应用读正文递进系统提示
262
190
  const statuses = new Map((teachingPack ? await tutorStatuses(root) : []).map((s) => [s.name, s]));
263
191
  for (const name of Object.keys(ws.config.tutors)) {
264
192
  const shipped = statuses.has(name);
@@ -283,6 +211,7 @@ export async function doctorWorkspace(override, opts = {}) {
283
211
  : shipped
284
212
  ? `cotutor init 补拷(拷自本包 agents/${name}.md),或把老师键改成文件里的 name`
285
213
  : `这是自家加的老师:写 agents/${name}.md(frontmatter name: ${name}),cotutor add ${name} --display <显示名> 可出模板;或把 cotutor.json 里这条删掉`,
214
+ cmd: !ok && shipped ? initCmd() : undefined,
286
215
  });
287
216
  const st = statuses.get(name);
288
217
  if (!st)
@@ -295,15 +224,16 @@ export async function doctorWorkspace(override, opts = {}) {
295
224
  required: false,
296
225
  detail: label[st.state],
297
226
  fix: st.state === 'upgradable' ? 'cotutor upgrade 换新版' : undefined,
227
+ cmd: st.state === 'upgradable' ? upgrade : undefined,
298
228
  });
299
229
  }
300
230
  const home = join(ws.dirs.agents, name);
301
231
  const there = (await statOrNull(home))?.isDirectory() ?? false;
302
- push({ name: `tutor.${name}.home`, ok: there, required: true, detail: there ? `agents/${name}/ 在(会话 cwd)` : `agents/${name}/ 不在`, fix: there ? undefined : 'cotutor init 补建' });
232
+ push({ name: `tutor.${name}.home`, ok: there, required: true, detail: there ? `agents/${name}/ 在(会话 cwd)` : `agents/${name}/ 不在`, fix: there ? undefined : 'cotutor init 补建', cmd: there ? undefined : initCmd() });
303
233
  // 音色:一老师一音色走 API 是正路;没配的孩子端只剩浏览器合成声(只该在测试里);hidden 的老师不对孩子说话,不用音色
304
234
  const tc = ws.config.tutors[name];
305
- if (tc.enabled && !tc.hidden && !tc.voice)
306
- push({ name: `tutor.${name}.voice`, ok: false, required: false, detail: `${tc.display} 没配音色(cotutor.json tutors.${name}.voice),卡上的声音(点读卡、单词卡)配不了——只适合测试`, fix: '工作台 /dev 的「音色」页「卡的配音」能听着挑(挑中直接写进去)' });
235
+ if (tc.enabled && !tc.hidden && !cardVoice(ws.config.tutors, name))
236
+ push({ name: `tutor.${name}.voice`, ok: false, required: false, detail: `${tc.display} 没配音色(cotutor.json tutors.${name}.voice),卡上的声音(点读卡、单词卡)配不了——只适合测试`, fix: '家长端「首页」点这位老师的「样子与声音」挑一个' });
307
237
  // 卡的清单(《卡片协议.md》「谁拿到哪些卡」):有脸的老师才递板书写法;只是报一声讲哪几种
308
238
  if (takesTutorRules(name)) {
309
239
  const kinds = tutorCardKinds(tc.cards);
@@ -317,6 +247,36 @@ export async function doctorWorkspace(override, opts = {}) {
317
247
  push({ name: `tutor.${name}.avatar`, ok: there, required: false, detail: there ? `头像 ${tc.avatar}` : inside ? `头像 ${tc.avatar} 不在,孩子端退回显示首字` : `头像 ${tc.avatar} 在 workspace 根之外,页面不给`, fix: there ? undefined : `figshot pick --workspace ${redactHome(root)} 存一张,或把 cotutor.json 的 tutors.${name}.avatar 改回 emoji` });
318
248
  }
319
249
  }
250
+ if (process.platform === 'win32' && (opts.firewall ?? probeEnv)) {
251
+ // 平板连不连得进来:Windows 只在第一次监听时问一次「允许访问」,点了取消就再也连不上(firewall.ts)
252
+ const { judgeFirewall, probeFirewall } = await import("./firewall.js");
253
+ const port = opts.port ?? ws.config.server.port;
254
+ const state = await probeFirewall(port);
255
+ if (state) {
256
+ const v = judgeFirewall(state, port, process.execPath);
257
+ push({ name: 'firewall', ok: v.ok, required: false, detail: v.detail, fix: v.fix });
258
+ }
259
+ }
260
+ if (probeEnv) {
261
+ // 端口起不起得来:被别的程序占了,后台服务只在日志里写一句就不起了(serve 自己要绑,它起来时不查这项)
262
+ const { probeHealth, realHost } = await import("./service.js");
263
+ const { portHolder, portState } = await import("./port.js");
264
+ const port = opts.port ?? ws.config.server.port;
265
+ const fix = '腾出这个端口;或改 cotutor.json 的 server.port,平板要重新扫码、重加主屏幕图标';
266
+ if (await probeHealth(port))
267
+ push({ name: 'server.port', ok: true, required: false, detail: `端口 ${port} 上 cotutor 在答应` });
268
+ else {
269
+ const state = await portState(port);
270
+ if (state === 'free')
271
+ push({ name: 'server.port', ok: true, required: false, detail: `端口 ${port} 空着,起得来` });
272
+ else if (state === 'denied')
273
+ push({ name: 'server.port', ok: false, required: false, detail: `系统不让这个用户监听端口 ${port},服务起不来`, fix });
274
+ else {
275
+ const p = await portHolder(realHost(), port);
276
+ push({ name: 'server.port', ok: false, required: false, detail: `端口 ${port} 被占着${p ? `:${p.name}(pid ${p.pid})在用` : ''},服务起不来`, fix });
277
+ }
278
+ }
279
+ }
320
280
  {
321
281
  // 舞台包:小课堂 / 画板卡要它;没有只是重卡打不开,轻卡与对话照常。做课包(cotutor chalk bake / snap)与服务端烤画面也开它,在本机的 Chrome 里
322
282
  const { stageBuilt } = await import("../http/stage.js");
@@ -324,15 +284,15 @@ export async function doctorWorkspace(override, opts = {}) {
324
284
  push({ name: 'stage.bundle', ok: built, required: false, detail: built ? 'dist/stage/ 在(舞台包:小课堂与画板卡的舞台)' : 'dist/stage/ 不在,小课堂与画板卡的舞台打不开(轻卡照常)', fix: built ? undefined : '仓库根 pnpm run build:stage(npm 装的包自带)' });
325
285
  const { chromePath } = await import("./chalk/browser.js");
326
286
  const chrome = chromePath(env);
327
- push({ name: 'chalk.chrome', ok: chrome !== null, required: false, detail: chrome ? `本机的 Chrome 在(${redactHome(chrome)}):做课包截图、烤画面用它` : '找不到 Chrome:cotutor chalk bake / snap 做不了,没烤过的课包孩子端在浏览器里现画(慢、iPad 上可能不顺)', fix: chrome ? undefined : '装 Google Chrome,或环境变量 CHROME 指到 Chrome / Chromium 的可执行文件' });
287
+ push({ name: 'chalk.chrome', ok: chrome !== null, required: false, detail: chrome ? `本机的浏览器在(${redactHome(chrome)}):做课包截图、烤画面用它` : '找不到 Chrome:cotutor chalk bake / snap 做不了,没烤过的课包孩子端在浏览器里现画(慢、iPad 上可能不顺)', fix: chrome ? undefined : '装 Google Chrome,或环境变量 CHROME 指到 Chrome / Chromium / Edge 的可执行文件' });
328
288
  const { CLAUDE_SKILLS_LINK, skillStatuses } = await import("./skills.js");
329
289
  for (const sk of teachingPack ? await skillStatuses(root) : []) {
330
290
  const label = { latest: sk.machine ? '机器件,最新' : '出厂件,最新', upgradable: sk.machine ? '机器件,和包里不一样(改过或包已更新)' : `出厂件,基于 ${sk.basedOn},包已更新`, custom: `自定义(基于 ${sk.basedOn})`, untracked: '自定义(没有出厂记录)', missing: sk.machine ? '缺(机器件,老师或家长的技能靠它)' : '缺' };
331
- push({ name: `skill.${sk.name}`, ok: sk.state !== 'missing' && sk.state !== 'upgradable', required: Boolean(sk.machine), detail: `${SKILLS_DIR}/${sk.name}/:${label[sk.state]}`, fix: sk.state === 'missing' ? 'cotutor init 补拷' : sk.state === 'upgradable' ? 'cotutor upgrade 换新版' : undefined });
291
+ push({ name: `skill.${sk.name}`, ok: sk.state !== 'missing' && sk.state !== 'upgradable', required: Boolean(sk.machine), detail: `${SKILLS_DIR}/${sk.name}/:${label[sk.state]}`, fix: sk.state === 'missing' ? 'cotutor init 补拷' : sk.state === 'upgradable' ? 'cotutor upgrade 换新版' : undefined, cmd: sk.state === 'missing' ? initCmd() : sk.state === 'upgradable' ? upgrade : undefined });
332
292
  }
333
293
  // Claude Code 只认 .claude/skills:那里该是指向 .agents/skills 的链;老师用不到,家长在 workspace 里用 Claude Code 才要
334
294
  const linked = (await lstat(join(root, CLAUDE_SKILLS_LINK)).catch(() => null))?.isSymbolicLink() ?? false;
335
- push({ name: 'skills.claude', ok: linked, required: false, detail: linked ? `${CLAUDE_SKILLS_LINK} 链到 ${SKILLS_DIR}(Claude Code 读的是同一份)` : `${CLAUDE_SKILLS_LINK} 不是链,在 workspace 里用 Claude Code 看不到 ${SKILLS_DIR}/ 的技能`, fix: linked ? undefined : 'cotutor upgrade 建链(是实体目录的话先把里面的挪进 .agents/skills/ 再删掉)' });
295
+ push({ name: 'skills.claude', ok: linked, required: false, detail: linked ? `${CLAUDE_SKILLS_LINK} 链到 ${SKILLS_DIR}(Claude Code 读的是同一份)` : `${CLAUDE_SKILLS_LINK} 不是链,在 workspace 里用 Claude Code 看不到 ${SKILLS_DIR}/ 的技能`, fix: linked ? undefined : 'cotutor upgrade 建链(是实体目录的话先把里面的挪进 .agents/skills/ 再删掉)', cmd: linked ? undefined : upgrade });
336
296
  // ---- activity:老师的家里 agents/<老师>/activities/<名>.md;出厂的看新旧,每份都过一遍 activity check ----
337
297
  {
338
298
  const { activityStatuses, checkActivities } = await import("./activities.js");
@@ -340,7 +300,7 @@ export async function doctorWorkspace(override, opts = {}) {
340
300
  const label = { latest: '出厂件,最新', upgradable: '出厂件,包已更新', custom: '自定义', untracked: '自定义(没有出厂记录)', missing: '缺', broken: '读不到' };
341
301
  for (const s of states.values())
342
302
  if (s.state === 'missing')
343
- push({ name: `activity.${s.key}`, ok: false, required: false, detail: `agents/${s.key.replace('/', '/activities/')}.md 不在`, fix: 'cotutor init 补拷' });
303
+ push({ name: `activity.${s.key}`, ok: false, required: false, detail: `agents/${s.key.replace('/', '/activities/')}.md 不在`, fix: 'cotutor init 补拷', cmd: initCmd() });
344
304
  // 学科色:手改 cotutor.json 写的颜色,新话题气泡的白字要压得住
345
305
  for (const [name, t] of Object.entries(ws.config.tutors)) {
346
306
  if (!t.color)
@@ -364,6 +324,7 @@ export async function doctorWorkspace(override, opts = {}) {
364
324
  required: false,
365
325
  detail: fixes.length ? `${origin};${fixes.length} 条要改,第一条:${fixes[0].line ? `第 ${fixes[0].line} 行 ` : ''}${fixes[0].text}` : origin,
366
326
  fix: fixes.length ? `cotutor activity check ${r.tutor} ${r.name} 看全` : st?.state === 'upgradable' ? 'cotutor upgrade 换新版' : undefined,
327
+ cmd: !fixes.length && st?.state === 'upgradable' ? upgrade : undefined,
367
328
  });
368
329
  }
369
330
  }
@@ -377,7 +338,7 @@ export async function doctorWorkspace(override, opts = {}) {
377
338
  push({ name: 'theme.manifest', ok: true, required: false, detail: `themes/${name}/:${Object.keys(t.manifest.tints).length} 个底色槽、${Object.keys(t.manifest.looks).length} 个字形槽、${Object.keys(t.manifest.pens).length} 支笔,default = ${t.manifest.default}` });
378
339
  }
379
340
  catch (err) {
380
- push({ name: 'theme.manifest', ok: false, required: false, detail: `themes/${name}/ 用不了(服务退回出厂 default):${err instanceof Error ? err.message : String(err)}`, fix: name === 'default' ? 'cotutor init 补拷(已有的不动)或 cotutor upgrade' : `修 themes/${name}/theme.json 与 kid.css,或把 cotutor.json 的 kid.theme 改回 default` });
341
+ push({ name: 'theme.manifest', ok: false, required: false, detail: `themes/${name}/ 用不了(服务退回出厂 default):${err instanceof Error ? err.message : String(err)}`, fix: name === 'default' ? 'cotutor init 补拷(已有的不动)或 cotutor upgrade' : `修 themes/${name}/theme.json 与 kid.css,或把 cotutor.json 的 kid.theme 改回 default`, cmd: name === 'default' ? initCmd() : undefined });
381
342
  }
382
343
  for (const th of teachingPack ? await themeStatuses(root) : []) {
383
344
  if (!th.shipped) {
@@ -385,7 +346,7 @@ export async function doctorWorkspace(override, opts = {}) {
385
346
  continue;
386
347
  }
387
348
  const label = { latest: '出厂件,最新', upgradable: `出厂件,基于 ${th.basedOn},包已更新`, custom: `自定义(基于 ${th.basedOn})`, untracked: '自定义(没有出厂记录)', missing: '缺' };
388
- push({ name: `theme.${th.name}.origin`, ok: th.state !== 'missing' && th.state !== 'upgradable', required: false, detail: `themes/${th.name}/:${label[th.state]}`, fix: th.state === 'missing' ? 'cotutor init 补拷' : th.state === 'upgradable' ? 'cotutor upgrade 换新版' : undefined });
349
+ push({ name: `theme.${th.name}.origin`, ok: th.state !== 'missing' && th.state !== 'upgradable', required: false, detail: `themes/${th.name}/:${label[th.state]}`, fix: th.state === 'missing' ? 'cotutor init 补拷' : th.state === 'upgradable' ? 'cotutor upgrade 换新版' : undefined, cmd: th.state === 'missing' ? initCmd() : th.state === 'upgradable' ? upgrade : undefined });
389
350
  }
390
351
  }
391
352
  }
@@ -508,36 +469,24 @@ export async function doctorWorkspace(override, opts = {}) {
508
469
  }
509
470
  // ---- 家长端与工作台的锁(src/app/http/access.ts):机器级 PIN;没设不算错,提一句 ----
510
471
  const pin = await readPin();
511
- push({ name: 'server.access', ok: pin !== null, required: false, detail: pin ? '设了 PIN:家长端 /parent 与工作台 /dev 要输一次,本机免密' : 'serve 对局域网全开,家长端 /parent 与工作台 /dev 没有锁:孩子在 iPad 上输 /parent 能看到答案,输 /dev 能改配置。家长在旁边、用完收回 iPad 的可以不设', fix: pin ? undefined : 'cotutor pin(机器级,所有 workspace 共用;cotutor pin off 撤掉)' });
472
+ push({ name: 'server.access', ok: pin !== null, required: false, detail: pin ? '已设 PIN' : '家长端、工作台没上锁', fix: pin ? undefined : 'cotutor pin' });
512
473
  // ---- HTTPS:iPad 上录音要;没有只提醒 ----
513
474
  const tls = httpsFiles(ws);
514
- push({ name: 'https', ok: tls !== null, required: false, detail: tls ? `${redactHome(tls.cert)}` : '没有证书,serve 走 HTTP(iPad / iPhone 上按住说话不可用)', fix: tls ? undefined : 'cotutor cert(需要 mkcert:brew install mkcert && mkcert -install)' });
515
- // ---- 板书写法怎么递给老师:有脸的老师用到的运行时逐个说清,run / resume 不一致点名 ----
516
- {
517
- const used = new Set();
518
- for (const [name, t] of Object.entries(ws.config.tutors))
519
- if (takesTutorRules(name) && t.enabled !== false)
520
- used.add(t.runtime ?? ws.config.runtimes.default);
521
- for (const name of used) {
522
- const rt = ws.config.runtimes[name];
523
- if (!rt || typeof rt === 'string')
524
- continue;
525
- if (isApiRuntime(rt)) {
526
- push({ name: `runtime.${name}.board`, ok: true, required: false, detail: 'API 运行时:板书写法连老师正文一起进系统提示' });
527
- continue;
528
- }
529
- const inRun = boardPreloaded({ run: rt.run, resume: rt.run });
530
- const inResume = boardPreloaded({ run: rt.resume, resume: rt.resume });
531
- const how = runtimeUses(rt, '{boardFile}') ? '{boardFile} 追加进系统提示' : runtimeUses(rt, '{systemBody}') ? '{systemBody} 连老师正文一起进系统提示' : '模板里写死的 SKILL.md 路径';
532
- if (inRun !== inResume)
533
- push({ name: `runtime.${name}.board`, ok: false, required: false, detail: `板书写法的预载只在 ${inRun ? 'run' : 'resume'} 模板里,两边不一致(压缩后系统提示按 resume 的旗标重建)`, fix: 'cotutor upgrade --config 补旗标,或手改 cotutor.json 让两条模板一致' });
534
- else
535
- push({ name: `runtime.${name}.board`, ok: true, required: false, detail: inRun ? `板书写法预载进系统提示(${how}),各话题共享缓存` : '板书写法由应用放进每个话题第一条(<cotutor-board>);这个 CLI 能从文件或参数收系统提示的话,模板里用 {boardFile} / {systemBody} 更省' });
536
- }
537
- }
475
+ const { mkcertHint, mkcertReady } = await import("./cert.js");
476
+ // mkcert -install 要开机密码或点弹窗,是人的事;做过了,签证书就不用人了
477
+ const canSign = !tls && probeEnv && await mkcertReady();
478
+ push({ name: 'https', ok: tls !== null, required: false, detail: tls ? `${redactHome(tls.cert)}` : '没有证书,serve 走 HTTP(平板、手机上按住说话不可用)', fix: tls ? undefined : `cotutor cert(需要 mkcert:${mkcertHint()}),签完 cotutor service restart`, cmd: canSign ? 'cotutor cert' : undefined });
538
479
  // ---- 百炼:老师靠实时模型开口,没配孩子端打不开;--live 真试连一次(握手就挂,不花钱) ----
480
+ // key 只认 ~/.config/cotutor/(2026-11-02 起);shell 里还 export 着旧的,说一句免得以为配了
539
481
  const bl = dashscopeAuth(env);
540
- push({ name: 'bailian', ok: Boolean(bl), required: true, detail: bl ? `百炼按量 key …${bl.key.slice(-4)}、业务空间 ${bl.workspaceId}` : '没有百炼按量的 key 或业务空间 id:孩子端打不开(只出「老师们还没到」)', fix: bl ? undefined : '家长端「首页」里配(在这台电脑上开,或者先 cotutor pin 设家长密码);Token Plan 的 sk-sp- 不行' });
482
+ const oldEnv = env.DASHSCOPE_API_KEY ? '(环境变量 DASHSCOPE_API_KEY 已经不读了)' : '';
483
+ push({ name: 'bailian', ok: Boolean(bl), required: true, detail: bl ? `百炼按量 key …${bl.key.slice(-4)}、业务空间 ${bl.workspaceId}` : `没有百炼按量的 key 或业务空间 id:孩子端打不开(只出「老师们还没到」)${oldEnv}`, fix: bl ? undefined : '家长端「首页」里配(在这台电脑上开,或者先 cotutor pin 设家长密码),存在 ~/.config/cotutor/bailian.json;Token Plan 的 sk-sp- 不行' });
484
+ // Token Plan 只有开发机有,没有就不提
485
+ const plan = tokenPlanKey(env.HOME || homedir());
486
+ if (plan)
487
+ push({ name: 'token-plan', ok: isPlanKey(plan), required: false, detail: isPlanKey(plan) ? `Token Plan key …${plan.slice(-4)}:Token Plan 有的模型先走它` : 'token-plan.json 里的 key 不是 sk-sp- 开头,不认', fix: isPlanKey(plan) ? undefined : '换成千问 Token Plan 的 key,或删掉 ~/.config/cotutor/token-plan.json' });
488
+ else if (env.QWENCLOUD_TOKEN_PLAN_API_KEY)
489
+ push({ name: 'token-plan', ok: false, required: false, detail: '环境变量 QWENCLOUD_TOKEN_PLAN_API_KEY 已经不读了,全走百炼按量', fix: '写进 ~/.config/cotutor/token-plan.json:{"apiKey": "sk-sp-…"}' });
541
490
  if (bl && opts.live) {
542
491
  const { probeRealtime } = await import("../../realtime/preview.js");
543
492
  const { probeMessage } = await import("../machine/bailian.js");
@@ -552,51 +501,72 @@ export async function doctorWorkspace(override, opts = {}) {
552
501
  {
553
502
  const bad = Object.entries(lv.voices).filter(([, v]) => !isRealtimeVoice(v));
554
503
  const who = listTutors(ws.config, { kidOnly: true }).map((t) => `${t.name} 用 ${mouthVoice(lv, t.name)}`).join('、') || '没有有脸的老师';
555
- push({ name: 'live.voices', ok: !bad.length, required: false, detail: bad.length ? `${bad.map(([t, v]) => `${t} 的「${v}」`).join('、')}:这个模型不认,嘴会一直不出声` : `嘴的音色:${who}`, fix: bad.length ? '工作台「音色」页「实时的嘴」里换一个(这个模型认的 56 个在 src/realtime/voices.ts),或改 cotutor.json 的 live.voices' : undefined });
504
+ push({ name: 'live.voices', ok: !bad.length, required: false, detail: bad.length ? `${bad.map(([t, v]) => `${t} 的「${v}」`).join('、')}:这个模型不认,嘴会一直不出声` : `嘴的音色:${who}`, fix: bad.length ? '家长端首页的「样子与声音」里换一个说话的声音,或改 cotutor.json 的 live.voices' : undefined });
556
505
  }
557
- // 旧配置允许加载与升级;退役运行时不探版本、不启动。
506
+ // 旧配置里的命令行运行时(Qwen Code 等)读得进来,用不了:用到它的老师发不出去
558
507
  const usedRuntimes = new Set([ws.config.runtimes.default, ...Object.values(ws.config.tutors).filter((t) => t.enabled).map((t) => t.runtime ?? ws.config.runtimes.default)]);
559
508
  for (const [name, rt] of Object.entries(ws.config.runtimes)) {
560
509
  if (typeof rt === 'string' || isApiRuntime(rt))
561
510
  continue;
562
511
  const issue = runtimeIssue(rt);
563
512
  if (issue)
564
- push({ name: `runtime.${name}`, ok: false, required: usedRuntimes.has(name), detail: issue, fix: '按 README.md 的「旧 workspace 换运行时」说明迁移' });
513
+ push({ name: `runtime.${name}`, ok: false, required: usedRuntimes.has(name), detail: issue, fix: '按 README.md 的「旧 workspace 换运行时」说明改' });
565
514
  }
566
- // ---- 运行时的 CLI 在不在 ----
515
+ // ---- 运行时:API 的走哪条线 ----
567
516
  if (probeEnv) {
568
517
  for (const [runtime, p] of Object.entries(ws.config.runtimes)) {
569
- if (runtime === 'default' || typeof p === 'string')
570
- continue;
571
- if (isApiRuntime(p)) {
572
- push({ name: `runtime.${runtime}`, ok: true, required: usedRuntimes.has(runtime), detail: `API 运行时(百炼 ${p.model}),不用装 CLI;凭证看 bailian 那条` });
573
- continue;
574
- }
575
- if (runtimeIssue(p))
576
- continue;
577
- const bin = runtimeCli(p.run);
578
- const required = usedRuntimes.has(runtime);
579
- // 没有老师用的 CLI 运行时(新装缺省 api,Qwen Code 那两条是备用):不探,免得没装 qwen 的家长看到一串「起不来」
580
- if (!required) {
581
- push({ name: `runtime.${runtime}`, ok: true, required: false, detail: `备用(${bin}),没有老师用它;要用就把老师的 runtime 改成 ${runtime},再跑一次 doctor` });
518
+ if (runtime === 'default' || typeof p === 'string' || !isApiRuntime(p))
582
519
  continue;
583
- }
584
- try {
585
- const { stdout } = await execFileP(bin, ['--version'], { timeout: 8000 });
586
- const version = stdout.trim().split('\n')[0];
587
- push({ name: `runtime.${runtime}`, ok: true, required, detail: `${bin} ${version}` });
588
- // 这个 CLI 的适配器要多看的几样(qwen:版本、百炼 key)
589
- for (const c of adapterFor(p).check?.({ version, env }) ?? [])
590
- push({ name: `runtime.${runtime}.${c.name}`, ok: c.ok, required, detail: c.detail, fix: c.fix });
591
- }
592
- catch {
593
- push({ name: `runtime.${runtime}`, ok: false, required, detail: `PATH 里没有 ${bin}`, fix: required ? `装 ${bin},或把 runtimes.default 改成装了的那个运行时` : undefined });
594
- }
520
+ const via = compatibleLine(p.model, env)?.plan ? '走 Token Plan' : '走百炼按量';
521
+ push({ name: `runtime.${runtime}`, ok: true, required: usedRuntimes.has(runtime), detail: `API 运行时(${p.model},${via});凭证看 bailian 那条` });
595
522
  }
596
523
  }
597
524
  }
598
525
  if (ws && opts.live)
599
526
  await probeLive(ws, push, env);
527
+ // ---- 后台服务(cotutor service):装了没有、写死的 node 与 bin 还在不在;接着电源会不会闲置睡眠(睡着时平板连不上) ----
528
+ if (probeEnv && root && (process.platform === 'darwin' || process.platform === 'linux' || process.platform === 'win32')) {
529
+ const { findService, realHost } = await import("./service.js");
530
+ const s = await findService({ ...realHost(), env }, root).catch(() => null);
531
+ if (s) {
532
+ // 菜单栏 / 任务栏图标:家长不开终端看状态靠它;家长自己关掉的不提
533
+ const { trayStatus, traySupported } = await import("./tray.js");
534
+ if (traySupported(process.platform)) {
535
+ const t = await trayStatus({ ...realHost(), env }).catch(() => null);
536
+ if (t && !t.off)
537
+ push({ name: 'tray', ok: t.installed, required: false, detail: t.installed ? `托盘图标装了${t.running ? ',在跑' : ',现在没在跑(点过退出,下次登录回来)'}` : '没装托盘图标:看状态只能开终端', fix: t.installed ? undefined : 'cotutor tray on', cmd: t.installed ? undefined : 'cotutor tray on' });
538
+ }
539
+ }
540
+ if (!s)
541
+ push({ name: 'service', ok: false, required: false, detail: '没装后台服务:在终端里跑的 cotutor serve,关了终端就停', fix: 'cotutor service install(登录后自己起来、崩了自己再起)', cmd: `cotutor service install --no-open${flag}` });
542
+ else {
543
+ const gone = s.args.slice(0, 2).filter((f) => !existsSync(f));
544
+ push({ name: 'service', ok: !gone.length, required: false, detail: gone.length ? `后台服务 ${s.label} 指的 ${gone.map((f) => redactHome(f)).join('、')} 不在了(换了 node 版本、包挪了地方)` : `后台服务 ${s.label}(cotutor service status 看在不在跑)`, fix: gone.length ? 'cotutor service install(照现在的 node 与包重写一遍)' : undefined, cmd: gone.length ? `cotutor service install${flag}` : undefined });
545
+ }
546
+ }
547
+ if (probeEnv && process.platform === 'darwin') {
548
+ const { acSleepMinutes } = await import("./service.js");
549
+ const { execFile } = await import('node:child_process');
550
+ const text = await new Promise((resolve) => execFile('/usr/bin/pmset', ['-g', 'custom'], { timeout: 5000, encoding: 'utf8' }, (_e, out) => resolve(String(out ?? ''))));
551
+ const min = acSleepMinutes(text);
552
+ if (min !== null)
553
+ push({ name: 'mac.sleep', ok: min === 0, required: false, detail: min === 0 ? '接着电源时不闲置睡眠' : `接着电源 ${min} 分钟没人碰就睡,睡着时平板连不上(有通话时 cotutor 拦着它睡,别的时候拦不了)`, fix: min === 0 ? undefined : '系统设置 › 电池 › 选项,打开「显示器关闭时,防止接通电源的 Mac 自动进入睡眠」(台式 Mac 在 系统设置 › 节能);合盖照样睡' });
554
+ }
555
+ if (probeEnv && process.platform === 'win32') {
556
+ const { winAcSleepSeconds } = await import("./service-windows.js");
557
+ const { execFile } = await import('node:child_process');
558
+ const text = await new Promise((resolve) => execFile('powercfg.exe', ['/query', 'SCHEME_CURRENT', 'SUB_SLEEP', 'STANDBYIDLE'], { timeout: 5000, encoding: 'latin1', windowsHide: true }, (_e, out) => resolve(String(out ?? ''))));
559
+ const sec = winAcSleepSeconds(text);
560
+ if (sec !== null)
561
+ push({ name: 'windows.sleep', ok: sec === 0, required: false, detail: sec === 0 ? '接着电源时不闲置睡眠' : `接着电源 ${Math.round(sec / 60)} 分钟没人碰就睡,睡着时平板连不上(有通话时 cotutor 拦着它睡,别的时候拦不了)`, fix: sec === 0 ? undefined : '设置 › 系统 › 电源和电池 › 屏幕、睡眠和休眠超时,「插入电源时,使设备在以下时间后进入睡眠状态」选「从不」;合盖看「电源按钮和盖子」那一项' });
562
+ }
563
+ // ---- 有没有新版本:只看服务问来存下的那份,体检不上网;什么时候升级家长定(要重起服务,孩子可能在上课),所以没有 cmd ----
564
+ {
565
+ const { readUpdateRecord } = await import("../machine/update.js");
566
+ const u = await readUpdateRecord();
567
+ if (!u.off && isNewer(u.latest, BUILD.version))
568
+ push({ name: 'update', ok: false, required: false, detail: `有新版本 ${u.latest}(现在是 ${BUILD.version})`, fix: `挑孩子不用的时候,依次运行:${upgradeSteps().join(' ; ')}` });
569
+ }
600
570
  // ---- 用户配置 ----
601
571
  try {
602
572
  const text = await readFile(USER_CONFIG, 'utf8');
@@ -611,7 +581,12 @@ export async function doctorWorkspace(override, opts = {}) {
611
581
  catch {
612
582
  push({ name: 'user-config', ok: true, required: false, detail: `未配置(可选):{"workspace": "…"} 写进 ${redactHome(USER_CONFIG)};cotutor init 会代劳` });
613
583
  }
614
- const gitThere = (await statOrNull(join(root, '.git'))) !== null;
615
- push({ name: 'git', ok: gitThere, required: false, detail: gitThere ? 'workspace是 git 仓' : 'workspace不在 git 里', fix: gitThere ? undefined : '建议 git init:老师记忆、账本、政策都是不可再生状态' });
616
- return { ok: checks.filter((c) => c.required).every((c) => c.ok), root, source, checks };
584
+ // sandbox(--sandbox 分出来的试用 workspace)随时能重新分,不要 git;说一句它是从哪分出来的
585
+ if (ws?.sandbox)
586
+ push({ name: 'sandbox', ok: true, required: false, detail: `试用 workspace:${ws.sandbox.forkedAt.slice(0, 10)} 从 ${redactHome(ws.sandbox.from)} 分出来;--sandbox --fresh 重新分` });
587
+ else {
588
+ const gitThere = (await statOrNull(join(root, '.git'))) !== null;
589
+ push({ name: 'git', ok: gitThere, required: false, detail: gitThere ? 'workspace是 git 仓' : 'workspace不在 git 里', fix: gitThere ? undefined : '建议 git init:老师记忆、账本、政策都是不可再生状态', cmd: gitThere ? undefined : `git init ${quoted}` });
590
+ }
591
+ return finish({ ok: checks.filter((c) => c.required).every((c) => c.ok), root, source, checks });
617
592
  }
@@ -0,0 +1,24 @@
1
+ /** 查到的样子;配置名是 Domain / Private / Public,规则上的 Any 覆盖全部 */
2
+ export interface FirewallState {
3
+ /** 连着的网络的类型(Get-NetConnectionProfile 的 NetworkCategory:Private / Public / DomainAuthenticated) */
4
+ networks: string[];
5
+ /** 防火墙开着的配置 */
6
+ enabled: string[];
7
+ /** 放行的规则(入站、允许、启用,按这个 node 或这个端口)各覆盖哪些配置,如 "Private"、"Private, Public"、"Any" */
8
+ allowed: string[];
9
+ /** 拦着这个 node 的规则覆盖哪些配置(家长在「允许访问」那一步点了取消,Windows 会建这种规则) */
10
+ blocked: string[];
11
+ }
12
+ export interface FirewallVerdict {
13
+ ok: boolean;
14
+ detail: string;
15
+ fix?: string;
16
+ }
17
+ /** 查一次的 PowerShell 脚本;node 的路径与端口写进去(单引号里的单引号写两个) */
18
+ export declare function firewallScript(node: string, port: number): string;
19
+ /** PowerShell 回的 JSON → 样子;一个元素的数组可能被拆成字符串,null 当空 */
20
+ export declare function parseFirewall(text: string): FirewallState | null;
21
+ /** 平板连不连得进来,怎么修 */
22
+ export declare function judgeFirewall(s: FirewallState, port: number, node: string): FirewallVerdict;
23
+ /** 真查一次(只在 Windows 上);查不了是 null */
24
+ export declare function probeFirewall(port: number, node?: string): Promise<FirewallState | null>;