cotutor 0.4.4 → 0.4.5

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 (55) hide show
  1. package/CHANGELOG.md +9 -1
  2. package/INSTALL.md +159 -0
  3. package/README.md +35 -19
  4. package/dist/app/cli/activities.d.ts +1 -1
  5. package/dist/app/cli/activities.js +2 -2
  6. package/dist/app/cli/cert.js +2 -1
  7. package/dist/app/cli/chalk/main.d.ts +0 -1
  8. package/dist/app/cli/chalk/main.js +8 -12
  9. package/dist/app/cli/commands.d.ts +50 -0
  10. package/dist/app/cli/commands.js +353 -0
  11. package/dist/app/cli/doctor.js +32 -17
  12. package/dist/app/cli/guide.d.ts +6 -0
  13. package/dist/app/cli/guide.js +17 -0
  14. package/dist/app/cli/init.d.ts +2 -0
  15. package/dist/app/cli/init.js +60 -6
  16. package/dist/app/cli/main.js +109 -46
  17. package/dist/app/cli/migrate.js +1 -1
  18. package/dist/app/cli/port.js +2 -10
  19. package/dist/app/cli/sandbox.js +2 -7
  20. package/dist/app/cli/service.d.ts +4 -1
  21. package/dist/app/cli/service.js +5 -2
  22. package/dist/app/cli/shipped.js +1 -1
  23. package/dist/app/cli/skeleton.d.ts +2 -0
  24. package/dist/app/cli/skeleton.js +3 -2
  25. package/dist/app/cli/skills.js +2 -2
  26. package/dist/app/cli/themes.js +2 -2
  27. package/dist/app/cli/tray.d.ts +6 -1
  28. package/dist/app/cli/tray.js +8 -5
  29. package/dist/app/cli/tutors.d.ts +2 -2
  30. package/dist/app/cli/tutors.js +4 -4
  31. package/dist/app/cli/update-cmd.js +4 -4
  32. package/dist/app/cli/usage.d.ts +26 -2
  33. package/dist/app/cli/usage.js +167 -38
  34. package/dist/app/http/app.js +1 -1
  35. package/dist/app/skills/analyze-doc.js +4 -8
  36. package/dist/app/skills/home-doc.js +3 -7
  37. package/dist/app/skills/tune-doc.js +4 -8
  38. package/dist/app/workspace.d.ts +16 -3
  39. package/dist/app/workspace.js +31 -9
  40. package/dist/runtime/home/home.js +1 -1
  41. package/dist/runtime/runner.js +2 -2
  42. package/dist/shared/home.d.ts +2 -0
  43. package/dist/shared/home.js +9 -0
  44. package/dist/ui/dev/dev-page.js +5 -5
  45. package/dist/ui/parent/parent-home-page.js +1 -1
  46. package/install.ps1 +5 -4
  47. package/install.sh +3 -3
  48. package/package.json +7 -5
  49. package/skills/cotutor-analyze/SKILL.md +1 -1
  50. package/skills/cotutor-analyze/references//345/221/275/344/273/244/344/270/216/346/226/207/344/273/266.md +168 -10
  51. package/skills/cotutor-home/SKILL.md +1 -1
  52. package/skills/cotutor-home/references//345/221/275/344/273/244.md +75 -7
  53. package/skills/cotutor-tune/SKILL.md +8 -8
  54. package/skills/cotutor-tune/references//345/255/227/346/256/265.md +164 -10
  55. package/skills/cotutor-tutor/SKILL.md +1 -1
@@ -1,11 +1,11 @@
1
1
  /**
2
- * cotutor init <slug>:建 ~/cotutor/<slug>/ 骨架。语义是**幂等补缺**——已有的文件与目录一律不动,缺什么补什么。
2
+ * cotutor init <slug>:建 ~/cotutor/<slug>/ 骨架,vault 建在旁边的 ~/cotutor/<slug>-vault/(--vault 另指;2026-11-09 起两边分开)。语义是**幂等补缺**——已有的文件与目录一律不动,缺什么补什么。
3
3
  * 三条边界(沿 drawtell init):政策文件只在缺失时写模板;用户配置只在还没指定 workspace 时补;不动 git。
4
4
  * 老师文件**拷贝**进 agents/<name>.md:拷进来就是家长的,想改就改;出厂 hash 记 .cotutor/shipped.json,
5
- * cotutor upgrade 据此换新或报 diff。老布局(.claude/agents、.claude/skills)先原样挪到新位置(migrate.ts 的 migrateLayout)。
5
+ * cotutor refresh 据此换新或报 diff。老布局(.claude/agents、.claude/skills)先原样挪到新位置(migrate.ts 的 migrateLayout)。
6
6
  */
7
7
  import { mkdir, readFile, stat, writeFile } from 'node:fs/promises';
8
- import { dirname, join, resolve } from 'node:path';
8
+ import { basename, dirname, join, resolve } from 'node:path';
9
9
  import { DIRS, GITIGNORE, LEDGER_FILES, REFERENCE_README, configTemplate, profileTemplate, shippedAgents, writeSchemaFile } from "./skeleton.js";
10
10
  import { installTutors, readManifest, writeManifest } from "./tutors.js";
11
11
  import { DEFAULT_GRADE, LEGACY_GRADE, requireGradePack } from "./grades.js";
@@ -16,10 +16,39 @@ import { migrateLayout } from "./migrate.js";
16
16
  import { choiceNote, choosePort, knownPorts } from "./port.js";
17
17
  import { installThemes } from "./themes.js";
18
18
  import { scanVault } from "../../memory/vault.js";
19
- import { CONFIG_FILE, ConfigError, UsageError, HOME_ROOT, USER_CONFIG, expandPath, loadWorkspace, parseConfig, readJson, redactHome } from "../workspace.js";
19
+ import { CONFIG_FILE, ConfigError, UsageError, HOME_ROOT, USER_CONFIG, defaultVaultOf, expandPath, homePath, isInside, knownWorkspaces, loadWorkspace, parseConfig, readJson, redactHome, vaultSeparate } from "../workspace.js";
20
20
  async function exists(p) {
21
21
  return (await stat(p).catch(() => null)) !== null;
22
22
  }
23
+ /** 新建时 vault 能不能放那:和 workspace 谁也不在谁里面,不是别的孩子的 workspace,不和别的孩子共用(一孩一 vault)。报错里 workspace 说「cotutor 文件夹」、vault 说「笔记文件夹」(docs/词表.md) */
24
+ async function checkVaultPlace(root, vault) {
25
+ if (isInside(vault, root))
26
+ throw new UsageError(`笔记文件夹(vault)不能放在孩子的 cotutor 文件夹里(${redactHome(vault)}):笔记是家长的,cotutor 文件夹是程序的运行记录,两边分开放。换个 --vault,缺省是旁边的 ${basename(defaultVaultOf(root))}`);
27
+ if (isInside(root, vault))
28
+ throw new UsageError(`孩子的 cotutor 文件夹不能放在笔记文件夹(vault)里面(${redactHome(root)} 在 ${redactHome(vault)} 里):两边分开放,换个 --dir 或 --vault`);
29
+ const st = await stat(vault).catch(() => null);
30
+ if (st && !st.isDirectory())
31
+ throw new UsageError(`笔记文件夹(vault)要是一个目录,${redactHome(vault)} 是文件;换个 --vault`);
32
+ if (await exists(join(vault, CONFIG_FILE)))
33
+ throw new UsageError(`${redactHome(vault)} 是一个孩子的 cotutor 文件夹(有 ${CONFIG_FILE}),不能当笔记文件夹;换个 --vault`);
34
+ for (const other of knownWorkspaces()) {
35
+ if (other === root)
36
+ continue;
37
+ if (isInside(vault, other) || isInside(other, vault))
38
+ throw new UsageError(`${redactHome(vault)} 和 ${redactHome(other)}(别的孩子的 cotutor 文件夹)叠在一起了,不能当笔记文件夹;换个 --vault`);
39
+ let theirs;
40
+ try {
41
+ theirs = loadWorkspace(other).paths.vault;
42
+ }
43
+ catch {
44
+ continue; // 那边配置坏了,doctor 去报
45
+ }
46
+ if (isInside(root, theirs))
47
+ throw new UsageError(`${redactHome(root)} 在 ${redactHome(other)} 的笔记文件夹里,不能在这里建 cotutor 文件夹;换个短名或 --dir`);
48
+ if (isInside(vault, theirs) || isInside(theirs, vault))
49
+ throw new UsageError(`${redactHome(vault)} 和 ${redactHome(other)} 的笔记文件夹(${redactHome(theirs)})重了:一个孩子一个笔记文件夹,换个 --vault`);
50
+ }
51
+ }
23
52
  export async function initWorkspace(opts) {
24
53
  const root = opts.dir ? expandPath(opts.dir, resolve(process.cwd())) : join(HOME_ROOT, opts.slug);
25
54
  const steps = [];
@@ -33,6 +62,18 @@ export async function initWorkspace(opts) {
33
62
  if (installed !== undefined && installed !== grade)
34
63
  throw new UsageError(`已安装 ${installed} 年级包,init 不切换年级`);
35
64
  const agents = await shippedAgents(pack.agentsDir);
65
+ // vault 只在新建 cotutor.json 时定(写进 paths.vault),以后 init 不改向;已有的再给 --vault,对不上就报错,不悄悄换
66
+ const vaultArg = opts.vault ? resolve(expandPath(opts.vault, resolve(process.cwd()))) : undefined; // resolve 去掉末尾的 /,好比较
67
+ let newVault;
68
+ if (!existing) {
69
+ newVault = vaultArg ?? defaultVaultOf(root);
70
+ await checkVaultPlace(root, newVault);
71
+ }
72
+ else if (vaultArg) {
73
+ const now = loadWorkspace(root).paths.vault;
74
+ if (resolve(now) !== vaultArg)
75
+ throw new UsageError(`已有 ${CONFIG_FILE},笔记文件夹(vault)在 ${redactHome(now)};init 只补缺,不换笔记文件夹。要换先把里面的笔记挪过去,再改 ${CONFIG_FILE} 的 paths.vault`);
76
+ }
36
77
  const rootExisted = await exists(root);
37
78
  if (!rootExisted)
38
79
  await mkdir(root, { recursive: true });
@@ -57,7 +98,7 @@ export async function initWorkspace(opts) {
57
98
  else {
58
99
  // 端口只在这里选一次,写进文件以后不变(port.ts);--port 给了就听它的
59
100
  const chosen = opts.port === undefined ? await choosePort(knownPorts()) : null;
60
- await writeFile(config, configTemplate({ slug: opts.slug, name: opts.name, port: opts.port ?? chosen?.port, tutors: agents }));
101
+ await writeFile(config, configTemplate({ slug: opts.slug, name: opts.name, port: opts.port ?? chosen?.port, vault: newVault && homePath(newVault), tutors: agents }));
61
102
  steps.push({ item: CONFIG_FILE, action: 'created', ...(chosen ? { note: choiceNote(chosen) } : {}) });
62
103
  }
63
104
  // 老师按 cotutor.json 里有谁走(家长自己加的也补目录与链);配置坏了就只管出厂的,doctor 去报
@@ -93,8 +134,18 @@ export async function initWorkspace(opts) {
93
134
  }
94
135
  // vault 侧(《obsidian仓库设计.md》§2):只补档案与参考 README,缺了才写;档案按 cotutor: profile 找(2026-09-17),vault 里已有一篇就不补;
95
136
  // 入口文件(每科每学期一篇)不自动建,doctor 提醒;日记由记账现建,计划家长自己写
137
+ let vaultAt = null;
96
138
  try {
97
139
  const ws = loadWorkspace(root);
140
+ vaultAt = ws.paths.vault;
141
+ if (!vaultSeparate(root, vaultAt))
142
+ steps.push({ item: 'vault', action: 'kept', note: '笔记文件夹(vault)在 cotutor 文件夹里(旧版 init 的布局),照旧能用;要分开看 cotutor doctor 的 vault.separate' });
143
+ else {
144
+ const there = await exists(vaultAt);
145
+ if (!there)
146
+ await mkdir(vaultAt, { recursive: true });
147
+ steps.push({ item: redactHome(vaultAt), action: there ? 'exists' : 'created', note: '笔记文件夹:档案、日记、老师的记忆,都是普通 markdown' });
148
+ }
98
149
  const profile = ws.paths.profile;
99
150
  const found = (await scanVault(ws)).notes.find((n) => n.props.cotutor === 'profile');
100
151
  if (found)
@@ -152,7 +203,10 @@ export async function initWorkspace(opts) {
152
203
  const suggestions = [];
153
204
  if (!(await exists(join(root, '.git'))))
154
205
  suggestions.push('git init(建议:老师记忆、账本、政策都是不可再生状态,git 是破坏后的兜底)');
155
- suggestions.push(`${opts.grade === undefined ? '档案里填 school_start(读一年级的年月,如 2025-09),老师才知道现在是几年级哪个学期;' : ''}想用自己的 Obsidian 库,改 cotutor.json 的 paths.vault`);
206
+ if (opts.grade === undefined)
207
+ suggestions.push('档案里填 school_start(读一年级的年月,如 2025-09),老师才知道现在是几年级哪个学期');
208
+ if (vaultAt && vaultSeparate(root, vaultAt))
209
+ suggestions.push(`孩子的档案、日记、老师的记忆在笔记文件夹(${redactHome(vaultAt)}):普通 markdown,不用装 Obsidian;想用 Obsidian 看,就把这个目录当库打开`);
156
210
  suggestions.push('cotutor service install 装成后台服务(登录后自己起来、关了终端照样跑;在终端里调试用 cotutor serve),第一次装会打开家长端(以后 cotutor open),在「首页」填百炼的按量 API Key 与业务空间 ID(老师的嘴和脑都用它;没填孩子端只出「老师们还没到」)');
157
211
  suggestions.push('在平板、手机上用:cotutor cert 签证书(要 mkcert),根证书装到设备上并打开信任,iPad / iPhone 的步骤见 README 里的《iPad 与 iPhone》');
158
212
  suggestions.push('可选:点读、单词卡的配音用百炼(和老师的嘴同一把 key),到家长端首页点老师的「样子与声音」挑声音;cotutor pin 给家长端与工作台加锁');
@@ -11,10 +11,11 @@ import { basename, join } from 'node:path';
11
11
  import { doctorWorkspace } from "./doctor.js";
12
12
  import { initWorkspace } from "./init.js";
13
13
  import { gradePacks } from "./grades.js";
14
+ import { guideText } from "./guide.js";
14
15
  import { makeCert } from "./cert.js";
15
16
  import { PIN_RE, USER_PIN_FILE, clearPin, readPin, setPin } from "../http/access.js";
16
- import { addTutorFile, upgradeTutors } from "./tutors.js";
17
- import { activityRunFor, checkActivities, upgradeActivities } from "./activities.js";
17
+ import { addTutorFile, restoreTutor, upgradeTutors } from "./tutors.js";
18
+ import { activityRunFor, checkActivities, restoreActivity, upgradeActivities } from "./activities.js";
18
19
  import { configGapsOf, migrateLayout, upgradeConfig } from "./migrate.js";
19
20
  import { linkClaudeSkills, upgradeSkills } from "./skills.js";
20
21
  import { addTheme, upgradeThemes } from "./themes.js";
@@ -32,7 +33,8 @@ import { startUpdateChecks } from "../machine/update.js";
32
33
  import { isNewer, upgradeSteps } from "../../shared/upgrade.js";
33
34
  import { serveMock } from "../mock/mock.js";
34
35
  import { UsageError, loadWorkspace, redactDeep, redactHome, workspaceReport } from "../workspace.js";
35
- import { USAGE } from "./usage.js";
36
+ import { checkArgs, commandHelp, topHelp, unknownCommand } from "./usage.js";
37
+ import { VALUED_FLAGS, commandSpec } from "./commands.js";
36
38
  import { PHOTO_MAX_BYTES, createContext } from "../http/app.js";
37
39
  import { MESSAGE_FROM } from "../../schema/index.js";
38
40
  import { formatEvent, laneFilter, parseEvents } from "../../runtime/journal/events.js";
@@ -47,6 +49,9 @@ function version() {
47
49
  function interactive(json) {
48
50
  return !json && Boolean(process.stdin.isTTY && process.stdout.isTTY);
49
51
  }
52
+ /** 参数写错了(不是运行时的状况):报错时补一句去看这条命令的帮助 */
53
+ class ArgError extends UsageError {
54
+ }
50
55
  function parseArgs(argv, valued) {
51
56
  const flags = {};
52
57
  const positionals = [];
@@ -57,13 +62,13 @@ function parseArgs(argv, valued) {
57
62
  const key = a.slice(2);
58
63
  if (valued.includes(key)) {
59
64
  const next = argv[i + 1];
60
- // --force 的值可省:upgrade --force <老师>,home publish --force
65
+ // --force 的值可省:upgrade --force <老师>(旧名),home publish --force
61
66
  if ((next === undefined || next.startsWith('--')) && key === 'force') {
62
67
  flags[key] = true;
63
68
  continue;
64
69
  }
65
70
  if (next === undefined || next.startsWith('--'))
66
- throw new UsageError(`选项 --${key} 需要一个值。\n${USAGE}`);
71
+ throw new ArgError(`选项 --${key} 需要一个值`);
67
72
  flags[key] = next;
68
73
  i++;
69
74
  }
@@ -103,12 +108,12 @@ async function backupBeforeUpgrade(root) {
103
108
  return `! 这次没备份成:${err instanceof Error ? err.message : String(err)}`;
104
109
  }
105
110
  }
106
- /** 不用 workspace 的命令:--sandbox 对它们没有意义 */
107
- const NO_WORKSPACE = ['grades', 'init', 'mock', 'pin', 'cert', 'tray', 'update'];
108
111
  export async function main(argv) {
109
112
  let json = false;
113
+ let helpFor;
110
114
  try {
111
- const { cmd, positionals, flags } = parseArgs(argv, ['workspace', 'dir', 'name', 'grade', 'port', 'from', 'runtime', 'force', 'display', 'subject', 'avatar', 'description', 'scenario', 'delay', 'lane', 'job', 'date', 'thread', 'at', 'photo', 'activity', 'param', 'runs', 'only', 'kid-model', 'pin', 'out', 'bundles', 'step', 'voice', 'tutor', 'instruction', 'log-dir', 'lines']);
115
+ const { cmd, positionals, flags } = parseArgs(argv, VALUED_FLAGS);
116
+ helpFor = cmd;
112
117
  json = flags.json === true;
113
118
  let workspace = typeof flags.workspace === 'string' ? flags.workspace : undefined;
114
119
  // --version / --help 是旗标不是命令,parseArgs 把它们收进 flags,cmd 拿不到,所以在 switch 前处理
@@ -116,18 +121,32 @@ export async function main(argv) {
116
121
  process.stdout.write(`${version()}\n`);
117
122
  return;
118
123
  }
119
- if (flags.help === true || cmd === undefined || cmd === '-h') {
120
- process.stdout.write(USAGE);
124
+ // 帮助:裸跑与 --help 是顶层(--all 连开发与调试的),cotutor <命令> --help 与 cotutor help <命令> 是那条命令的
125
+ if (cmd === 'help' && positionals[0] && commandSpec(positionals[0])) {
126
+ process.stdout.write(commandHelp(positionals[0]));
127
+ return;
128
+ }
129
+ if (flags.help === true || cmd === undefined || cmd === '-h' || cmd === 'help') {
130
+ process.stdout.write(cmd && commandSpec(cmd) ? commandHelp(cmd) : topHelp({ all: flags.all === true }));
121
131
  return;
122
132
  }
133
+ // 照命令表先查一遍:打错的旗标、用错子命令的旗标、多余的参数都报错,不悄悄忽略
134
+ const spec = commandSpec(cmd);
135
+ if (!spec) {
136
+ helpFor = undefined;
137
+ throw new ArgError(unknownCommand(cmd));
138
+ }
139
+ const wrong = checkArgs(spec, positionals, flags);
140
+ if (wrong)
141
+ throw new ArgError(wrong);
123
142
  // --sandbox:要 workspace 的命令都换到它的试用副本(src/app/cli/sandbox.ts),下面照常把它当 --workspace
124
143
  if (flags.fresh === true && flags.sandbox !== true)
125
144
  throw new UsageError('--fresh 只跟 --sandbox 一起用:cotutor serve --sandbox --fresh 重新分出试用 workspace');
126
145
  if (flags.sandbox === true) {
127
- if (NO_WORKSPACE.includes(cmd))
128
- throw new UsageError(`${cmd} 不用 workspace,--sandbox 没有意义`);
129
146
  if (cmd === 'service')
130
147
  throw new UsageError('试用 workspace 不装后台服务:试的时候 cotutor serve --sandbox 在前台跑');
148
+ if (!spec.sandbox)
149
+ throw new UsageError(`${cmd} 不用 workspace,--sandbox 没有意义`);
131
150
  const sb = await openSandbox(workspace, { fresh: flags.fresh === true });
132
151
  workspace = sb.root;
133
152
  if (!json) {
@@ -137,6 +156,10 @@ export async function main(argv) {
137
156
  }
138
157
  }
139
158
  switch (cmd) {
159
+ case 'guide': {
160
+ process.stdout.write(guideText(version()));
161
+ return;
162
+ }
140
163
  case 'grades': {
141
164
  const packs = (await gradePacks()).map(({ grade }) => ({ grade }));
142
165
  process.stdout.write(json ? `${JSON.stringify(packs, null, 2)}\n` : `可安装的教学包:${packs.map((p) => `${p.grade} 年级`).join('、') || '无'}\n`);
@@ -145,13 +168,13 @@ export async function main(argv) {
145
168
  case 'init': {
146
169
  const slug = positionals[0];
147
170
  if (!slug || !/^[a-z0-9][a-z0-9-]*$/.test(slug))
148
- throw new UsageError(`init 需要一个 slug(小写字母数字连字符,是短名不是真名),如 cotutor init ming。\n${USAGE}`);
171
+ throw new ArgError(`init 需要一个 slug(小写字母数字连字符,是短名不是真名),如 cotutor init ming`);
149
172
  const port = typeof flags.port === 'string' ? Number(flags.port) : undefined;
150
173
  if (port !== undefined && !(Number.isInteger(port) && port > 0 && port < 65536))
151
174
  throw new UsageError('--port 要是 1–65535 的整数');
152
175
  if (flags.grade !== undefined && (typeof flags.grade !== 'string' || !/^[1-6]$/.test(flags.grade)))
153
176
  throw new UsageError('--grade 要是 1–6 的整数,如 --grade 2');
154
- const r = await initWorkspace({ slug, grade: flags.grade === undefined ? undefined : Number(flags.grade), dir: typeof flags.dir === 'string' ? flags.dir : undefined, name: typeof flags.name === 'string' ? flags.name : undefined, port });
177
+ const r = await initWorkspace({ slug, grade: flags.grade === undefined ? undefined : Number(flags.grade), dir: typeof flags.dir === 'string' ? flags.dir : undefined, vault: typeof flags.vault === 'string' ? flags.vault : undefined, name: typeof flags.name === 'string' ? flags.name : undefined, port });
155
178
  if (json)
156
179
  process.stdout.write(`${JSON.stringify(redactDeep(r), null, 2)}\n`);
157
180
  else {
@@ -179,9 +202,13 @@ export async function main(argv) {
179
202
  const [sub, name] = positionals;
180
203
  if (!sub) {
181
204
  const b = await backup.makeBackup(target, { why: 'manual', version: BUILD.version });
205
+ if (json) {
206
+ process.stdout.write(`${JSON.stringify(redactDeep({ ok: true, backup: b, dir: backup.backupsOf(ws.root), vault: b.vault ?? null }), null, 2)}\n`);
207
+ return;
208
+ }
182
209
  process.stdout.write(`备份好了:${backup.describeBackup(b)}\n 在 ${redactHome(backup.backupsOf(ws.root))}\n 不含录音(wav)和能重新生成的文件${b.audioSkipped ? `,这次跳过 ${b.audioSkipped} 个录音` : ''}\n`);
183
210
  if (b.vault === 'unreadable')
184
- process.stdout.write(`! vault 读不了(${redactHome(ws.paths.vault)}),这份里没有它\n`);
211
+ process.stdout.write(`! 笔记文件夹(vault)读不了(${redactHome(ws.paths.vault)}),这份里没有它\n`);
185
212
  return;
186
213
  }
187
214
  if (sub === 'list') {
@@ -197,11 +224,17 @@ export async function main(argv) {
197
224
  return;
198
225
  }
199
226
  if (sub !== 'restore' || !name)
200
- throw new UsageError(`cotutor backup [list | restore <名字> [--yes]]\n${USAGE}`);
227
+ throw new ArgError('backup restore 要备份的名字,如 cotutor backup restore <名字> --yes(cotutor backup list 看有哪些)');
201
228
  if (!(await backup.listBackups(ws.root)).some((b) => b.name === name))
202
229
  throw new UsageError(`没有这份备份:${name}(cotutor backup list 看有哪些)`);
203
230
  if (flags.yes !== true) {
204
- process.stdout.write(`会把孩子的文件夹恢复成 ${name} 那时的样子:备份里有的文件都覆盖回去,之后新多出来的不删。\n恢复前会先把现在的样子也备份一份,恢复错了还能回来。服务会停一下。录音不在备份里,不受影响。\n确定的话加 --yes\n`);
231
+ // 不加 --yes 只说会做什么,以 1 退:agent 拿到 needsYes 也不能自己补,要家长明确说要恢复
232
+ if (json) {
233
+ process.stdout.write(`${JSON.stringify({ ok: false, needsYes: true, backup: name, would: '备份里有的文件覆盖回去,之后新多出来的不删;先把现在的也备一份;服务停一下;录音不受影响' }, null, 2)}\n`);
234
+ process.exitCode = 1;
235
+ return;
236
+ }
237
+ process.stdout.write(`会把孩子的 cotutor 文件夹恢复成 ${name} 那时的样子:备份里有的文件都覆盖回去,之后新多出来的不删。\n恢复前会先把现在的样子也备份一份,恢复错了还能回来。服务会停一下。录音不在备份里,不受影响。\n确定的话加 --yes\n`);
205
238
  process.exitCode = 1;
206
239
  return;
207
240
  }
@@ -216,13 +249,18 @@ export async function main(argv) {
216
249
  if (service && health)
217
250
  await controlService(host, ws.root, 'stop');
218
251
  const r = await backup.restoreBackup(target, name, { version: BUILD.version });
252
+ const restart = Boolean(service && health);
253
+ if (restart)
254
+ await controlService(host, ws.root, 'start');
255
+ if (json) {
256
+ process.stdout.write(`${JSON.stringify(redactDeep({ ok: true, backup: name, files: r.files, safety: r.safety, vaultCopy: r.vaultCopy, restarted: restart }), null, 2)}\n`);
257
+ return;
258
+ }
219
259
  process.stdout.write(`恢复好了:${r.files} 个文件。恢复前的样子留在 ${r.safety}\n`);
220
260
  if (r.vaultCopy)
221
- process.stdout.write(`外面的 vault 没动;要拿回来,那一份的 md 在 ${redactHome(r.vaultCopy)}\n`);
222
- if (service && health) {
223
- await controlService(host, ws.root, 'start');
261
+ process.stdout.write(`笔记文件夹(vault)没动;要拿回来,那一份的 md 在 ${redactHome(r.vaultCopy)}\n`);
262
+ if (restart)
224
263
  process.stdout.write('服务重起了\n');
225
- }
226
264
  return;
227
265
  }
228
266
  case 'doctor': {
@@ -331,7 +369,7 @@ export async function main(argv) {
331
369
  // 菜单栏 / 任务栏图标(tray.ts);tray run 由 bin/cotutor.js 直接走轻入口,不到这里
332
370
  const sub = positionals[0];
333
371
  if (sub !== 'on' && sub !== 'off' && sub !== 'status')
334
- throw new UsageError(`tray 后面跟 on / off / status 之一。\n${USAGE}`);
372
+ throw new ArgError(`tray 后面跟 on / off / status 之一`);
335
373
  const host = realHost();
336
374
  if (!traySupported(host.platform))
337
375
  throw new UsageError(`${host.platform} 上没做托盘图标(GNOME 缺省不显示托盘):cotutor service status 看服务状态`);
@@ -378,7 +416,7 @@ export async function main(argv) {
378
416
  }
379
417
  const SUBS = ['install', 'uninstall', 'start', 'stop', 'restart', 'status', 'logs'];
380
418
  if (!sub || !SUBS.includes(sub))
381
- throw new UsageError(`service 后面跟 ${SUBS.join(' / ')} 之一。\n${USAGE}`);
419
+ throw new ArgError(`service 后面跟 ${SUBS.join(' / ')} 之一`);
382
420
  const host = realHost();
383
421
  const platform = servicePlatform(host.platform);
384
422
  const ws = loadWorkspace(workspace);
@@ -624,7 +662,7 @@ export async function main(argv) {
624
662
  const name = positionals[0];
625
663
  const display = typeof flags.display === 'string' ? flags.display : undefined;
626
664
  if (!name || !display)
627
- throw new UsageError(`add 需要老师名和 --display 显示名,如 cotutor add science-tutor --display 科学老师 --subject 科学 --avatar 🔬。\n${USAGE}`);
665
+ throw new ArgError(`add 需要老师名和 --display 显示名,如 cotutor add science-tutor --display 科学老师 --subject 科学 --avatar 🔬`);
628
666
  const ws = loadWorkspace(workspace);
629
667
  if (ws.config.tutors[name])
630
668
  throw new UsageError(`cotutor.json 里已经有 ${name} 了;要改人设去老师团页或直接改文件`);
@@ -642,7 +680,7 @@ export async function main(argv) {
642
680
  case 'add-theme': {
643
681
  const name = positionals[0];
644
682
  if (!name)
645
- throw new UsageError(`add-theme 需要主题名,如 cotutor add-theme dark。\n${USAGE}`);
683
+ throw new ArgError(`add-theme 需要主题名,如 cotutor add-theme dark`);
646
684
  const { root } = resolveRoot(workspace);
647
685
  const r = await addTheme(root, name, typeof flags.from === 'string' ? flags.from : 'default');
648
686
  if (json)
@@ -661,9 +699,32 @@ export async function main(argv) {
661
699
  } }), { anyway: flags.anyway === true });
662
700
  return;
663
701
  }
664
- case 'upgrade': {
702
+ case 'reset': {
703
+ // 只把点名的出厂老师 / activity 换回出厂那份(工作台「恢复出厂」同一条路),别的不碰;现在那份存 .bak
704
+ if (!positionals.length)
705
+ throw new ArgError('reset 要说换回哪几份,如 cotutor reset math-tutor 或 cotutor reset chinese-tutor/你说我画');
706
+ const { root } = resolveRoot(workspace);
707
+ const said = await backupBeforeUpgrade(root);
708
+ if (said && !json)
709
+ process.stdout.write(`${said}\n`);
710
+ const done = [];
711
+ for (const name of positionals) {
712
+ const [tutor, activity] = name.split('/');
713
+ const r = activity ? await restoreActivity(root, tutor, activity) : await restoreTutor(root, tutor);
714
+ done.push({ name, bak: r.bak });
715
+ }
716
+ if (json)
717
+ process.stdout.write(`${JSON.stringify(redactDeep({ ok: true, reset: done }), null, 2)}\n`);
718
+ else
719
+ for (const d of done)
720
+ process.stdout.write(`✓ ${d.name} 换回出厂的${d.bak ? `;你那份存在 ${redactHome(d.bak)}` : '(本来就和出厂的一样)'}\n`);
721
+ return;
722
+ }
723
+ // upgrade 是 refresh 的旧名:0.4.x 的 cotutor update 换完程序会用新程序跑 upgrade --workspace <孩子>,一直要认;--force 的活现在归 reset
724
+ case 'upgrade':
725
+ case 'refresh': {
665
726
  if (flags.grade !== undefined)
666
- throw new UsageError('upgrade 只更新已安装的教学包,不能用 --grade 切换年级');
727
+ throw new UsageError(`${cmd} 只更新已安装的教学包,不能用 --grade 切换年级`);
667
728
  const { root } = resolveRoot(workspace);
668
729
  // 升级前留一份:新代码弄坏了能回来。备份不成不拦升级,说一声。--dry-run 不动文件,不用
669
730
  if (flags['dry-run'] !== true) {
@@ -685,7 +746,7 @@ export async function main(argv) {
685
746
  for (const st of r.installed)
686
747
  process.stdout.write(`✓ ${st.item.padEnd(24)} ${st.note ?? '建好了'}\n`);
687
748
  process.stdout.write(dryRun
688
- ? `\n${r.gaps.length} 项要改,还没动文件:cotutor upgrade --config 真改(只加缺的、删不再用的出厂段,你改过的值不动)\n`
749
+ ? `\n${r.gaps.length} 项要改,还没动文件:cotutor refresh --config 真改(只加缺的、删不再用的出厂段,你改过的值不动)\n`
689
750
  : `\n改了 ${r.gaps.length} 项到 ${redactHome(r.file)};服务在跑的话不用重启(改了就热重载)。\n`);
690
751
  }
691
752
  return;
@@ -715,7 +776,7 @@ export async function main(argv) {
715
776
  for (const s of steps) {
716
777
  process.stdout.write(`${s.action === 'kept-custom' ? '!' : '✓'} ${s.name.padEnd(18)} ${word[s.action]}${s.basedOn && s.action !== 'latest' ? `(基于 ${s.basedOn})` : ''}\n`);
717
778
  if (s.diff?.length) {
718
- process.stdout.write(` 你的 vs 新版(- 你的 / + 新版),想用新版:cotutor upgrade --force ${s.name}\n`);
779
+ process.stdout.write(` 你的 vs 新版(- 你的 / + 新版),想用新版:cotutor reset ${s.name}\n`);
719
780
  for (const l of s.diff.slice(0, 40))
720
781
  process.stdout.write(` ${l}\n`);
721
782
  if (s.diff.length > 40)
@@ -725,7 +786,7 @@ export async function main(argv) {
725
786
  for (const s of activitySteps) {
726
787
  process.stdout.write(`${s.action === 'kept-custom' ? '!' : '✓'} activity ${s.key.padEnd(18)} ${word[s.action]}${s.from ? `(原来叫「${s.from}」)` : ''}${s.basedOn && s.action !== 'latest' ? `(基于 ${s.basedOn})` : ''}\n`);
727
788
  if (s.diff?.length) {
728
- process.stdout.write(` 你的 vs 新版(- 你的 / + 新版),想用新版:cotutor upgrade --force ${s.key}\n`);
789
+ process.stdout.write(` 你的 vs 新版(- 你的 / + 新版),想用新版:cotutor reset ${s.key}\n`);
729
790
  for (const l of s.diff.slice(0, 40))
730
791
  process.stdout.write(` ${l}\n`);
731
792
  if (s.diff.length > 40)
@@ -733,19 +794,19 @@ export async function main(argv) {
733
794
  }
734
795
  }
735
796
  if (gaps.length)
736
- process.stdout.write(`! 配置有 ${gaps.length} 项要改(新出厂老师 / 运行时 / 命令模板旗标 / 不再用的段):cotutor upgrade --config --dry-run 先看\n`);
797
+ process.stdout.write(`! 配置有 ${gaps.length} 项要改(新出厂老师 / 运行时 / 命令模板旗标 / 不再用的段):cotutor refresh --config --dry-run 先看\n`);
737
798
  }
738
799
  return;
739
800
  }
740
801
  case 'activity': {
741
802
  const sub = positionals[0];
742
803
  if (sub !== 'check' && sub !== 'eval')
743
- throw new UsageError(`activity 后面跟 check / eval,如 cotutor activity check chinese-tutor 你说我画。\n${USAGE}`);
804
+ throw new ArgError(`activity 后面跟 check / eval,如 cotutor activity check chinese-tutor 你说我画`);
744
805
  const ws = loadWorkspace(workspace);
745
806
  const [, tutor, name] = positionals;
746
807
  if (sub === 'eval') {
747
808
  if (!tutor || !name)
748
- throw new UsageError(`activity eval 要老师和名字,如 cotutor activity eval chinese-tutor 你说我画。\n${USAGE}`);
809
+ throw new ArgError(`activity eval 要老师和名字,如 cotutor activity eval chinese-tutor 你说我画`);
749
810
  const runs = typeof flags.runs === 'string' ? Number(flags.runs) : 3;
750
811
  if (!Number.isInteger(runs) || runs < 1 || runs > 10)
751
812
  throw new UsageError('--runs 要是 1–10 的整数');
@@ -777,7 +838,7 @@ export async function main(argv) {
777
838
  }
778
839
  const results = await checkActivities(ws.root, ws.config.tutors, { tutor, name });
779
840
  if (!results.length)
780
- throw new UsageError(tutor ? `agents/${tutor}/activities/ 里没有${name ? ` ${name}.md` : ' activity 文件'}` : '这个 workspace 里还没有 activity 文件(cotutor upgrade 补出厂的)');
841
+ throw new UsageError(tutor ? `agents/${tutor}/activities/ 里没有${name ? ` ${name}.md` : ' activity 文件'}` : '这个 workspace 里还没有 activity 文件(cotutor refresh 补出厂的)');
781
842
  const fixes = results.reduce((n, r) => n + r.issues.filter((i) => i.level === 'fix').length, 0);
782
843
  if (json)
783
844
  process.stdout.write(`${JSON.stringify(redactDeep({ ok: fixes === 0, activities: results.map((r) => ({ tutor: r.tutor, name: r.name, file: r.file, issues: r.issues, params: r.activity?.params ?? [], state: r.activity?.state ?? [], gates: r.activity?.gates ?? [], tools: r.activity?.tools.map((t) => t.tool) ?? [] })) }), null, 2)}\n`);
@@ -849,7 +910,7 @@ export async function main(argv) {
849
910
  case 'pack': {
850
911
  const [tutor, ...rest] = positionals;
851
912
  if (!tutor)
852
- throw new UsageError(`pack 需要老师名,如 cotutor pack math-tutor "讲讲退位"。\n${USAGE}`);
913
+ throw new ArgError(`pack 需要老师名,如 cotutor pack math-tutor "讲讲退位"`);
853
914
  const ws = loadWorkspace(workspace);
854
915
  const { packDryRun } = await import("../../runtime/runner.js");
855
916
  const from = typeof flags.from === 'string' ? flags.from : 'kid';
@@ -890,7 +951,7 @@ export async function main(argv) {
890
951
  case 'show': {
891
952
  const [tutor, jobArg, dateArg] = positionals;
892
953
  if (!tutor || !jobArg)
893
- throw new UsageError(`show 需要老师名和 job,如 cotutor show math-tutor 1620-1 2026-09-12(日期缺省今天)。\n${USAGE}`);
954
+ throw new ArgError(`show 需要老师名和 job,如 cotutor show math-tutor 1620-1 2026-09-12(日期缺省今天)`);
894
955
  const ws = loadWorkspace(workspace);
895
956
  const { rawView } = await import("../http/raw-view.js");
896
957
  const { localDate } = await import("../../shared/time.js");
@@ -929,7 +990,7 @@ export async function main(argv) {
929
990
  case 'replay': {
930
991
  const [tutor, jobArg, dateArg] = positionals;
931
992
  if (!tutor || !jobArg)
932
- throw new UsageError(`replay 需要老师名和 job,如 cotutor replay math-tutor 1620-1 2026-09-12(日期缺省今天)。\n${USAGE}`);
993
+ throw new ArgError(`replay 需要老师名和 job,如 cotutor replay math-tutor 1620-1 2026-09-12(日期缺省今天)`);
933
994
  const ws = loadWorkspace(workspace);
934
995
  const { compareReplay, formatCompare, startReplay } = await import("../replay.js");
935
996
  const { localDate } = await import("../../shared/time.js");
@@ -950,7 +1011,7 @@ export async function main(argv) {
950
1011
  case 'compare': {
951
1012
  const [tutor, evalJobArg, dateArg] = positionals;
952
1013
  if (!tutor)
953
- throw new UsageError(`compare 需要老师名,如 cotutor compare math-tutor 1712-1 2026-09-12。\n${USAGE}`);
1014
+ throw new ArgError(`compare 需要老师名,如 cotutor compare math-tutor 1712-1 2026-09-12`);
954
1015
  const ws = loadWorkspace(workspace);
955
1016
  const { compareReplay, formatCompare, listReplays } = await import("../replay.js");
956
1017
  const { localDate } = await import("../../shared/time.js");
@@ -978,7 +1039,7 @@ export async function main(argv) {
978
1039
  case 'trace': {
979
1040
  const [tutor, jobArg, dateArg] = positionals;
980
1041
  if (!tutor || !jobArg)
981
- throw new UsageError(`trace 需要老师名和 job,如 cotutor trace math-tutor 1620-1 2026-09-12(日期缺省今天)。\n${USAGE}`);
1042
+ throw new ArgError(`trace 需要老师名和 job,如 cotutor trace math-tutor 1620-1 2026-09-12(日期缺省今天)`);
982
1043
  const ws = loadWorkspace(workspace);
983
1044
  const { localDate } = await import("../../shared/time.js");
984
1045
  const { conversationFiles } = await import("../../schema/index.js");
@@ -1003,7 +1064,7 @@ export async function main(argv) {
1003
1064
  const [tutor, thread, score] = positionals;
1004
1065
  const n = Number(score);
1005
1066
  if (!tutor || !thread || !(n >= 1 && n <= 5 && Number.isInteger(n)))
1006
- throw new UsageError(`rate 需要老师名、话题 id 和 1–5 的星,如 cotutor rate math-tutor 1620-1 4。\n${USAGE}`);
1067
+ throw new ArgError(`rate 需要老师名、话题 id 和 1–5 的星,如 cotutor rate math-tutor 1620-1 4`);
1007
1068
  const ws = loadWorkspace(workspace);
1008
1069
  const date = typeof flags.date === 'string' ? flags.date : localDate(new Date());
1009
1070
  const index = await rateThread(ws, tutor, date, thread, n);
@@ -1016,7 +1077,7 @@ export async function main(argv) {
1016
1077
  case 'bookkeep': {
1017
1078
  const [tutor] = positionals;
1018
1079
  if (!tutor)
1019
- throw new UsageError(`bookkeep 需要老师名,如 cotutor bookkeep math-tutor。\n${USAGE}`);
1080
+ throw new ArgError(`bookkeep 需要老师名,如 cotutor bookkeep math-tutor`);
1020
1081
  const ctx = createContext(loadWorkspace(workspace));
1021
1082
  const date = typeof flags.date === 'string' ? flags.date : localDate(new Date());
1022
1083
  const only = typeof flags.thread === 'string' ? [flags.thread] : undefined;
@@ -1047,7 +1108,7 @@ export async function main(argv) {
1047
1108
  case 'home': {
1048
1109
  const sub = positionals[0];
1049
1110
  if (sub !== 'check' && sub !== 'publish' && sub !== 'show')
1050
- throw new UsageError(`home 后面跟 check / publish / show,如 cotutor home check。\n${USAGE}`);
1111
+ throw new ArgError(`home 后面跟 check / publish / show,如 cotutor home check`);
1051
1112
  const ws = loadWorkspace(workspace);
1052
1113
  const now = new Date();
1053
1114
  const home = await import("../../runtime/home/store.js");
@@ -1109,7 +1170,7 @@ export async function main(argv) {
1109
1170
  const activityName = typeof flags.activity === 'string' ? flags.activity : undefined;
1110
1171
  const text = words.join(' ') || activityName || '';
1111
1172
  if (!tutor || (!text && !photoFiles.length))
1112
- throw new UsageError(`send 需要老师名和消息,如 cotutor send math-tutor "这一步为什么要借位"。\n${USAGE}`);
1173
+ throw new ArgError(`send 需要老师名和消息,如 cotutor send math-tutor "这一步为什么要借位"`);
1113
1174
  const from = typeof flags.from === 'string' ? flags.from : 'kid';
1114
1175
  if (!MESSAGE_FROM.includes(from))
1115
1176
  throw new UsageError(`--from 只能是 ${MESSAGE_FROM.join(' / ')}`);
@@ -1165,15 +1226,17 @@ export async function main(argv) {
1165
1226
  return;
1166
1227
  }
1167
1228
  default:
1168
- throw new UsageError(`未知命令 '${cmd}'。\n${USAGE}`);
1229
+ throw new ArgError(unknownCommand(cmd));
1169
1230
  }
1170
1231
  }
1171
1232
  catch (err) {
1172
1233
  if (err instanceof UsageError) {
1234
+ // 参数写错了:一句话 + 去哪看用法,不再把整篇帮助倒出来
1235
+ const help = err instanceof ArgError ? (helpFor && commandSpec(helpFor) ? `cotutor ${helpFor} --help` : 'cotutor --help') : undefined;
1173
1236
  if (json)
1174
- process.stdout.write(`${JSON.stringify({ ok: false, error: 'usage', message: err.message })}\n`);
1237
+ process.stdout.write(`${JSON.stringify({ ok: false, error: 'usage', message: err.message, ...(help ? { help } : {}) })}\n`);
1175
1238
  else
1176
- process.stderr.write(`cotutor: ${err.message}\n`);
1239
+ process.stderr.write(`cotutor: ${err.message}\n${help ? ` 用法:${help}\n` : ''}`);
1177
1240
  process.exitCode = 2;
1178
1241
  return;
1179
1242
  }
@@ -4,7 +4,7 @@
4
4
  * cotutor.json 是**政策文件**:init 只在缺失时写模板,已有的那份机器永不改(家长的决定不由机器代做)。
5
5
  * 代价是包更新带来的新出厂件不会自己出现在老 workspace 里,而且「没有」的表现是**静默**的——
6
6
  * 所以有这一层:把「出厂模板有、你这份没有」算出来(doctor 的 `config.migrate` 点名、家长端设置页顶部提示),
7
- * `cotutor upgrade --config` 只**补缺**——加缺的键;家长写过的值一个都不动(改小的预算、换掉的 default 运行时全都原样留着)。
7
+ * `cotutor refresh --config` 只**补缺**——加缺的键;家长写过的值一个都不动(改小的预算、换掉的 default 运行时全都原样留着)。
8
8
  * 唯一删的是 RETIRED 里点了名的出厂段(机器写进去、应用已经不读的,如 2026-10-31 的 tts);家长自己加的键不在表里,不碰。
9
9
  *
10
10
  * 差异是**现算的**:拿家长自己的 slug / name / port 生成一份出厂模板,和她这份逐项比,
@@ -7,12 +7,11 @@
7
7
  * 旧 workspace 写着什么就是什么;cotutor.json 没写端口的走 DEFAULT_PORT,不跟着这里变。
8
8
  */
9
9
  import { createConnection, createServer } from 'node:net';
10
- import { homedir } from 'node:os';
11
10
  import { join } from 'node:path';
12
11
  import { DEFAULT_PORT } from "../../schema/index.js";
13
12
  import { holderScript, parseHolder, parseLsof, portHolder } from "./port-holder.js";
14
13
  import { probeHealth } from "./service.js";
15
- import { CONFIG_FILE, USER_CONFIG, UsageError, expandPath, homeWorkspaces, readJson } from "../workspace.js";
14
+ import { CONFIG_FILE, UsageError, knownWorkspaces, readJson } from "../workspace.js";
16
15
  export const PORT_CANDIDATES = [443, ...Array.from({ length: 9 }, (_, i) => 8443 + i * 2)];
17
16
  /** sandbox 用哪个端口 */
18
17
  export const sandboxPort = (port) => (port === 65535 ? port - 1 : port + 1);
@@ -70,15 +69,8 @@ export function choiceNote(c) {
70
69
  }
71
70
  /** 这台电脑上别的 workspace 写着的端口(~/cotutor/ 下的,加上用户配置指的那个):还没起的也要让开 */
72
71
  export function knownPorts() {
73
- const roots = new Set(homeWorkspaces());
74
- try {
75
- const user = readJson(USER_CONFIG);
76
- if (typeof user?.workspace === 'string' && user.workspace)
77
- roots.add(expandPath(user.workspace, homedir()));
78
- }
79
- catch { /* 用户配置坏了 doctor 去报 */ }
80
72
  const ports = [];
81
- for (const root of roots) {
73
+ for (const root of knownWorkspaces()) {
82
74
  try {
83
75
  const raw = readJson(join(root, CONFIG_FILE));
84
76
  if (!raw)
@@ -5,21 +5,16 @@
5
5
  * 已经在 sandbox 里(cwd 在里面、--workspace 指过来)就用它;--fresh 从它记着的真 workspace 重新分,不套娃。
6
6
  */
7
7
  import { copyFile, cp, mkdir, readdir, rename, rm, stat, writeFile } from 'node:fs/promises';
8
- import { basename, dirname, isAbsolute, join, relative, sep } from 'node:path';
8
+ import { basename, dirname, join, relative } from 'node:path';
9
9
  import { PATH_ROLES, ROLE_DEFAULTS, WORKSPACE_ROLES } from "../../schema/index.js";
10
10
  import { SANDBOX_DIR, SANDBOX_KEEP_MACHINE, SANDBOX_MARK, SANDBOX_SKIP, SANDBOX_TITLE, SANDBOX_VAULT } from "../../schema/workspace.js";
11
- import { CONFIG_FILE, UsageError, expandPath, loadWorkspace, parseConfig, readJson, readSandboxMark, redactHome, resolveRoot } from "../workspace.js";
11
+ import { CONFIG_FILE, UsageError, expandPath, isInside as inside, loadWorkspace, parseConfig, readJson, readSandboxMark, redactHome, resolveRoot } from "../workspace.js";
12
12
  import { sandboxPort } from "./port.js";
13
13
  import { DIRS, LEDGER_FILES } from "./skeleton.js";
14
14
  /** vault 侧指在外面也不怕的角色:只读的材料,老师不往里写 */
15
15
  const READ_ONLY_ROLES = ['textbooks', 'reference'];
16
16
  /** ~/cotutor/ray → ~/cotutor/.sandbox/ray */
17
17
  export const sandboxRootOf = (real) => join(dirname(real), SANDBOX_DIR, basename(real));
18
- /** p 是 dir 本身或在它里面 */
19
- function inside(p, dir) {
20
- const r = relative(dir, p);
21
- return r === '' || (r !== '..' && !r.startsWith(`..${sep}`) && !isAbsolute(r));
22
- }
23
18
  async function exists(p) {
24
19
  return stat(p).then(() => true, () => false);
25
20
  }
@@ -82,7 +82,10 @@ export interface ServiceSpec {
82
82
  export declare const xml: (s: string) => string;
83
83
  /** macOS 的 LaunchAgent */
84
84
  export declare function launchdPlist(s: ServiceSpec): string;
85
- /** Linux 的 systemd 用户服务 */
85
+ /**
86
+ * Linux 的 systemd 用户服务。WorkingDirectory= 不认引号(只有 ExecStart=、Environment= 这种列表才认),
87
+ * 加了引号 systemd 当它是相对路径,整个服务起不来(2026-11-09 在 Ubuntu 22.04 / systemd 249 上撞到);它只要 % 写两个
88
+ */
86
89
  export declare function systemdUnit(s: ServiceSpec): string;
87
90
  /** 从服务文件里读回跑的参数(找它是哪个 workspace 的) */
88
91
  export declare function serviceArgs(text: string): string[];
@@ -218,7 +218,10 @@ ${s.args.map((a) => ` ${str(a)}`).join('\n')}
218
218
  }
219
219
  /** systemd 的一个参数:双引号括起来,反斜杠与引号转义,% 写两个 */
220
220
  const sd = (v) => `"${v.replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('%', '%%')}"`;
221
- /** Linux 的 systemd 用户服务 */
221
+ /**
222
+ * Linux 的 systemd 用户服务。WorkingDirectory= 不认引号(只有 ExecStart=、Environment= 这种列表才认),
223
+ * 加了引号 systemd 当它是相对路径,整个服务起不来(2026-11-09 在 Ubuntu 22.04 / systemd 249 上撞到);它只要 % 写两个
224
+ */
222
225
  export function systemdUnit(s) {
223
226
  return `# cotutor service install 写的;改了会被下次 install 盖掉。卸掉:cotutor service uninstall --workspace ${s.root}
224
227
  [Unit]
@@ -227,7 +230,7 @@ After=network-online.target
227
230
 
228
231
  [Service]
229
232
  ExecStart=${s.args.map(sd).join(' ')}
230
- WorkingDirectory=${sd(s.root)}
233
+ WorkingDirectory=${s.root.replaceAll('%', '%%')}
231
234
  Environment=${sd(`PATH=${s.path}`)}
232
235
  Environment=${sd(`COTUTOR_SERVICE=${s.label}`)}
233
236
  Restart=on-failure