ronin-skillforce 0.1.0 → 0.2.0-beta.0

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 (155) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/LICENSE +202 -21
  3. package/NOTICE +19 -0
  4. package/README.md +38 -5
  5. package/bin/cli.js +15 -3
  6. package/bin/commands/archive.js +192 -0
  7. package/bin/commands/capstone-status.js +82 -0
  8. package/bin/commands/create.js +69 -12
  9. package/bin/commands/import.js +5 -5
  10. package/bin/commands/status.js +1 -1
  11. package/bin/commands/studio-autostart.js +414 -0
  12. package/bin/commands/studio-state.js +159 -0
  13. package/bin/commands/studio.js +351 -17
  14. package/bin/commands/trail.js +153 -3
  15. package/bin/commands/web.js +8 -10
  16. package/bin/course-reader.js +6 -15
  17. package/bin/platforms/_shared.js +154 -28
  18. package/bin/platforms/claude.js +23 -42
  19. package/bin/platforms/cursor.js +23 -20
  20. package/bin/platforms/gemini.js +18 -19
  21. package/bin/platforms/opencode.js +47 -28
  22. package/package.json +5 -4
  23. package/plugin.json +1 -1
  24. package/shared/SKILL.md +102 -72
  25. package/shared/agents/coach.md +19 -16
  26. package/shared/agents/navigator.md +27 -34
  27. package/shared/agents/professor.md +16 -34
  28. package/shared/agents/researcher.md +1 -1
  29. package/shared/agents/spotter.md +3 -4
  30. package/shared/commands/professor/archive.md +65 -72
  31. package/shared/commands/professor/capstone-review.md +5 -5
  32. package/shared/commands/professor/capstone.md +3 -3
  33. package/shared/commands/professor/done.md +59 -31
  34. package/shared/commands/professor/hint.md +14 -2
  35. package/shared/commands/professor/navigator.md +27 -1
  36. package/shared/commands/professor/new-topic.md +3 -3
  37. package/shared/commands/professor/next.md +9 -5
  38. package/shared/commands/professor/note.md +6 -6
  39. package/shared/commands/professor/progress.md +1 -1
  40. package/shared/commands/professor/publish.md +5 -5
  41. package/shared/commands/professor/quiz.md +2 -2
  42. package/shared/commands/professor/recall.md +1 -1
  43. package/shared/commands/professor/review.md +14 -0
  44. package/shared/commands/professor/skill-export.md +3 -4
  45. package/shared/commands/professor/spotter.md +21 -4
  46. package/shared/commands/professor/stuck.md +24 -0
  47. package/shared/commands/professor/switch.md +1 -1
  48. package/shared/commands/professor/syllabus.md +1 -1
  49. package/shared/commands/professor/template-export.md +2 -2
  50. package/shared/commands/professor/worktrees.md +3 -7
  51. package/shared/hooks/pre-compact.js +4 -15
  52. package/studio/dist/assets/BetaReportPanel-BJ9HIAnz.js +1 -0
  53. package/studio/dist/assets/{Capstone-Cv9kH6LY.js → Capstone-CoUU-jP7.js} +1 -1
  54. package/studio/dist/assets/{CodeWorkspace-BwCEINWo.js → CodeWorkspace-BNrL4qvR.js} +2 -2
  55. package/studio/dist/assets/{Course-BJ_hafUP.js → Course-BMGTXus_.js} +1 -1
  56. package/studio/dist/assets/{Editor-CV0mNhlz.js → Editor-Cw9j3cnp.js} +1 -1
  57. package/studio/dist/assets/{Home-C1ry89ak.js → Home-D2woITq5.js} +1 -1
  58. package/studio/dist/assets/{Lesson-2e9TD9yL.js → Lesson-CYQhrDCZ.js} +2 -2
  59. package/studio/dist/assets/{NewCourse-BZeOg2xn.js → NewCourse-CMpwVRQb.js} +1 -1
  60. package/studio/dist/assets/{Progress-CRc_xEWC.js → Progress-2rS19Fur.js} +1 -1
  61. package/studio/dist/assets/{Review-6QEbHEfk.js → Review-SlqK-hjp.js} +1 -1
  62. package/studio/dist/assets/{Settings-NFnqVQui.js → Settings-t42u7n9H.js} +1 -1
  63. package/studio/dist/assets/{TutorPanel-DXQ6Z3BY.js → TutorPanel-DQDWIU7f.js} +3 -3
  64. package/studio/dist/assets/{abnfDiagram-WEYOIA3C-qeSjKlGM.js → abnfDiagram-WEYOIA3C-jTUmGBuB.js} +1 -1
  65. package/studio/dist/assets/{anthropic-D7NiQCUH.js → anthropic-iIm2l85r.js} +3 -3
  66. package/studio/dist/assets/{architecture-WOLXFQ4H-COG-m35l.js → architecture-WOLXFQ4H-Ch9Ld_vv.js} +1 -1
  67. package/studio/dist/assets/{architectureDiagram-ZHVYKT53-VO7uKoeD.js → architectureDiagram-ZHVYKT53-Dy-z6wsy.js} +1 -1
  68. package/studio/dist/assets/{blockDiagram-RJAE7QA3-BGJF_qiL.js → blockDiagram-RJAE7QA3-2fHVrc6N.js} +1 -1
  69. package/studio/dist/assets/{bridge-BTh0AeNX.js → bridge-Cr0q4rLp.js} +1 -1
  70. package/studio/dist/assets/{c4Diagram-XDPPPE54-C-S10RgU.js → c4Diagram-XDPPPE54-BuuKlG4V.js} +1 -1
  71. package/studio/dist/assets/{capstone-Amn6Hztq.js → capstone-DhwBeNqk.js} +1 -1
  72. package/studio/dist/assets/{card-store-BrbrtX9w.js → card-store-CVMBc-Pv.js} +1 -1
  73. package/studio/dist/assets/channel-M_JkdHrL.js +1 -0
  74. package/studio/dist/assets/{chunk-2E7WHVMD-fv-iqTyC.js → chunk-2E7WHVMD-lZdQWttk.js} +1 -1
  75. package/studio/dist/assets/{chunk-4EMNKR2T-CY9IZrgv.js → chunk-4EMNKR2T-C6kbMwke.js} +1 -1
  76. package/studio/dist/assets/{chunk-55XCU76K-DyTZz7v_.js → chunk-55XCU76K-rkBzkZog.js} +1 -1
  77. package/studio/dist/assets/{chunk-7S6XSTFO-BNMzTLRW.js → chunk-7S6XSTFO-Skg347QF.js} +1 -1
  78. package/studio/dist/assets/{chunk-AQDTSKZB-CEYANohH.js → chunk-AQDTSKZB-0pSDBJ23.js} +1 -1
  79. package/studio/dist/assets/{chunk-DSNLSHJZ-CDk5EXjH.js → chunk-DSNLSHJZ-BaeQDl2i.js} +1 -1
  80. package/studio/dist/assets/{chunk-HAGFOTED-hNAi1zh_.js → chunk-HAGFOTED-BEm00juw.js} +1 -1
  81. package/studio/dist/assets/{chunk-HNE4Q36P-COJnSYoZ.js → chunk-HNE4Q36P-DyCR-LvS.js} +1 -1
  82. package/studio/dist/assets/{chunk-NQOKRVIF-BU6KVxRr.js → chunk-NQOKRVIF-BNJ9uMmB.js} +1 -1
  83. package/studio/dist/assets/{chunk-UHW7PFBP-DYvxL9JG.js → chunk-UHW7PFBP-DqJczBLs.js} +1 -1
  84. package/studio/dist/assets/{chunk-VQ5W62QI-CuRfk65j.js → chunk-VQ5W62QI-CVWYSP5Q.js} +1 -1
  85. package/studio/dist/assets/{chunk-WQQL4CDK-KaG7x-Mb.js → chunk-WQQL4CDK-DJf0Dx05.js} +1 -1
  86. package/studio/dist/assets/{chunk-YZO67HFG-DbuqIZmk.js → chunk-YZO67HFG-BFUm_JHA.js} +1 -1
  87. package/studio/dist/assets/{chunk-ZAUX2LTX-bk4ivUmc.js → chunk-ZAUX2LTX-C5ETkkSE.js} +1 -1
  88. package/studio/dist/assets/{chunk-ZCHMQSOR-B_LElfwh.js → chunk-ZCHMQSOR-WVX9r9PZ.js} +1 -1
  89. package/studio/dist/assets/{classDiagram-v2-4MYWR3UA-DtCJ6H_X.js → classDiagram-v2-4MYWR3UA-CI77SpuR.js} +1 -1
  90. package/studio/dist/assets/{course-export-DpaOTT3a.js → course-export-C8jSPuda.js} +1 -1
  91. package/studio/dist/assets/{cynefin-EF2NZ3EQ-D9btHJvU.js → cynefin-EF2NZ3EQ-CLffmM25.js} +1 -1
  92. package/studio/dist/assets/{cynefinDiagram-WF24UDRP-CsFJuv27.js → cynefinDiagram-WF24UDRP-BvLwocOb.js} +1 -1
  93. package/studio/dist/assets/{dagre-NK4GIZVX-Cg1Duec7.js → dagre-NK4GIZVX-C2nMAVdh.js} +1 -1
  94. package/studio/dist/assets/{diagram-CBK3MWTC-_KU2m5gd.js → diagram-CBK3MWTC-Bid-pdax.js} +1 -1
  95. package/studio/dist/assets/{diagram-I5XIIV2D-5RaBrwUA.js → diagram-I5XIIV2D-DYJeAvzl.js} +1 -1
  96. package/studio/dist/assets/{diagram-ODIL2UCW-C0VKxoD3.js → diagram-ODIL2UCW-B06X2r_F.js} +1 -1
  97. package/studio/dist/assets/{diagram-PFBEJUXE-BaHcZEP2.js → diagram-PFBEJUXE-CwY--5Rz.js} +1 -1
  98. package/studio/dist/assets/{diagram-RJA3I4VV-cwSAZ90p.js → diagram-RJA3I4VV-4SqhH8zw.js} +1 -1
  99. package/studio/dist/assets/{diagram-TYUNFSNR-BJImxCEN.js → diagram-TYUNFSNR-CrW4AIM9.js} +1 -1
  100. package/studio/dist/assets/{ebnfDiagram-V4P3TTPT-OQQLRXcm.js → ebnfDiagram-V4P3TTPT-xeoCmH-d.js} +1 -1
  101. package/studio/dist/assets/{elk-QONTBCTQ-BJGhQHxQ.js → elk-QONTBCTQ-Dadmz6iS.js} +1 -1
  102. package/studio/dist/assets/{erDiagram-KYQIGQAA-D91EKSep.js → erDiagram-KYQIGQAA-CSuH0bbF.js} +1 -1
  103. package/studio/dist/assets/{eventmodeling-K75KTNOO-DgdTRgnw.js → eventmodeling-K75KTNOO-CciVfx3I.js} +1 -1
  104. package/studio/dist/assets/flowDiagram-IUGLLWBU-DgNR54qK.js +1 -0
  105. package/studio/dist/assets/{ganttDiagram-R7GPNTJY-CSYLPWEI.js → ganttDiagram-R7GPNTJY-C0wtA-2y.js} +1 -1
  106. package/studio/dist/assets/{gitGraph-VSP46ZUC-D6GWaV9y.js → gitGraph-VSP46ZUC-BXoDzaA3.js} +1 -1
  107. package/studio/dist/assets/{gitGraphDiagram-PDYL7XHY-CaV31bvT.js → gitGraphDiagram-PDYL7XHY-vZBlNRPe.js} +1 -1
  108. package/studio/dist/assets/{index-DioXTZVc.js → index-CS8XPYbB.js} +2 -2
  109. package/studio/dist/assets/{info-OHQRW6UA-BN-x8GoN.js → info-OHQRW6UA-bs2Qpf7P.js} +1 -1
  110. package/studio/dist/assets/{infoDiagram-MVDRKAX5-CIose_cy.js → infoDiagram-MVDRKAX5-rPJio6-1.js} +1 -1
  111. package/studio/dist/assets/{ishikawaDiagram-HPEHV2GE-DCihSNBw.js → ishikawaDiagram-HPEHV2GE-CXf9XFff.js} +1 -1
  112. package/studio/dist/assets/{journeyDiagram-BOSHW7O7-Bz3o3RIX.js → journeyDiagram-BOSHW7O7-C3MSH79o.js} +1 -1
  113. package/studio/dist/assets/{kanban-definition-FNSA4HC5-BUyqvlFE.js → kanban-definition-FNSA4HC5-Bdh7SB4n.js} +1 -1
  114. package/studio/dist/assets/{line-B08T8KlK.js → line-CHkoQXJF.js} +1 -1
  115. package/studio/dist/assets/{mermaid-parser.core-BYwreSUy.js → mermaid-parser.core-Do1oxD1S.js} +3 -3
  116. package/studio/dist/assets/{mermaid.core-DTO1oDmq.js → mermaid.core-DgZUD0sU.js} +4 -4
  117. package/studio/dist/assets/{mindmap-definition-6OY57UG3-BGLwKheY.js → mindmap-definition-6OY57UG3-PUaK6oE_.js} +1 -1
  118. package/studio/dist/assets/{node.browser-BISniYn8.js → node.browser-DkpMze_r.js} +1 -1
  119. package/studio/dist/assets/{ollama-De5w84tG.js → ollama-bJNp3jaD.js} +1 -1
  120. package/studio/dist/assets/{openai-compatible-Joy1oK3L.js → openai-compatible-0tmAyMlN.js} +1 -1
  121. package/studio/dist/assets/{packet-JDAUHWVQ-CDIMjHS_.js → packet-JDAUHWVQ-vf2FtqFr.js} +1 -1
  122. package/studio/dist/assets/{pegDiagram-ECIUJNBX-DWXm4mwS.js → pegDiagram-ECIUJNBX-NEVrgPTY.js} +1 -1
  123. package/studio/dist/assets/{pie-XZMESJXO-CY3QQiKZ.js → pie-XZMESJXO-BIWydVNZ.js} +1 -1
  124. package/studio/dist/assets/{pieDiagram-GNMPY74T-M1aCnI7W.js → pieDiagram-GNMPY74T-BI9_u6rT.js} +1 -1
  125. package/studio/dist/assets/{quadrantDiagram-D57K4MC3-BFFtO7U5.js → quadrantDiagram-D57K4MC3-ryNpoBC5.js} +1 -1
  126. package/studio/dist/assets/{radar-ABXABTNO-DT0W4_Xn.js → radar-ABXABTNO-D8TjnvBB.js} +1 -1
  127. package/studio/dist/assets/{railroad-I3PHUGI6-_VCpgZR5.js → railroad-I3PHUGI6-BmEDV5kf.js} +1 -1
  128. package/studio/dist/assets/{railroad-abnf-LNEFI6M7-DkSpQ5YY.js → railroad-abnf-LNEFI6M7-Cs_sArMc.js} +1 -1
  129. package/studio/dist/assets/{railroad-ebnf-DWYD2UWJ-BqWILdGb.js → railroad-ebnf-DWYD2UWJ-DYuCq46d.js} +1 -1
  130. package/studio/dist/assets/{railroad-peg-JR7BVNR7-BB0bQL-Z.js → railroad-peg-JR7BVNR7-BcXSgdMy.js} +1 -1
  131. package/studio/dist/assets/{railroadDiagram-Y72NRBL7-CjLKjIdJ.js → railroadDiagram-Y72NRBL7-DBcc8b2c.js} +1 -1
  132. package/studio/dist/assets/{rehype-katex-C-sKddsH.js → rehype-katex-BfUIq5As.js} +1 -1
  133. package/studio/dist/assets/{remark-math-C-ox67hE.js → remark-math-XvZOjmDO.js} +1 -1
  134. package/studio/dist/assets/{requirementDiagram-EQYSW5RA-CnwzZqR8.js → requirementDiagram-EQYSW5RA-CVcBgM1L.js} +1 -1
  135. package/studio/dist/assets/{sankeyDiagram-SY24JHO5-CPDugTUl.js → sankeyDiagram-SY24JHO5-DjZ81hDs.js} +1 -1
  136. package/studio/dist/assets/{sequenceDiagram-BLEHNHQU-1dLfeU9Y.js → sequenceDiagram-BLEHNHQU-BNwOa2S7.js} +1 -1
  137. package/studio/dist/assets/{stateDiagram-v2-A6IESKWZ-DR7CmEjj.js → stateDiagram-v2-A6IESKWZ-BFWa5lEx.js} +1 -1
  138. package/studio/dist/assets/{swimlanes-B3ENRKFQ-BAWB9omQ.js → swimlanes-B3ENRKFQ-LfM_Jg6Z.js} +1 -1
  139. package/studio/dist/assets/swimlanesDiagram-AYQRV4AK-BDmpoiGp.js +8 -0
  140. package/studio/dist/assets/{timeline-definition-JXYKGLGB-DNkj8z77.js → timeline-definition-JXYKGLGB-BwGzkWgZ.js} +1 -1
  141. package/studio/dist/assets/{treeView-D4JQ5SDB-BA-ouhMb.js → treeView-D4JQ5SDB-MLk3VuRt.js} +1 -1
  142. package/studio/dist/assets/{treemap-SAJKECNS-BrrGxsAb.js → treemap-SAJKECNS-D8iSjp7L.js} +1 -1
  143. package/studio/dist/assets/{usecaseDiagram-WPOLBNNX-BB3D0Dt_.js → usecaseDiagram-WPOLBNNX-BqxpaNlT.js} +1 -1
  144. package/studio/dist/assets/{vennDiagram-Q3Y3IDCW-BrtqtKTE.js → vennDiagram-Q3Y3IDCW-CpeUDMhQ.js} +1 -1
  145. package/studio/dist/assets/{wardley-7MLQ67FV-BKkemDql.js → wardley-7MLQ67FV-tAMbCZAj.js} +1 -1
  146. package/studio/dist/assets/{wardleyDiagram-IVAIDKWA-DFnI3P5z.js → wardleyDiagram-IVAIDKWA-CDJViWzt.js} +1 -1
  147. package/studio/dist/assets/{xychartDiagram-4URNHDU4-Da0DZG_q.js → xychartDiagram-4URNHDU4-CwaQrd3_.js} +1 -1
  148. package/studio/dist/index.html +1 -1
  149. package/templates/claude/plugin.json +1 -1
  150. package/templates/gemini/GEMINI.md +18 -16
  151. package/web/server.js +3 -3
  152. package/studio/dist/assets/BetaReportPanel-8NrDLiPm.js +0 -1
  153. package/studio/dist/assets/channel-SJiUQu_N.js +0 -1
  154. package/studio/dist/assets/flowDiagram-IUGLLWBU-CVnJYMum.js +0 -1
  155. package/studio/dist/assets/swimlanesDiagram-AYQRV4AK-qeuHru16.js +0 -8
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Background state for `skillforce studio` (RON-297): the `studio.json` snapshot that `--status`
3
+ * and `--stop` read, plus the two probes they need (pid liveness, one-shot health check).
4
+ *
5
+ * Nothing here writes during a read path: `writeState` is only called by a running server, and
6
+ * `readState`/`probeStudioHealth` never create a file or a token.
7
+ */
8
+ import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'fs';
9
+ import { homedir } from 'os';
10
+ import { dirname, join } from 'path';
11
+
12
+ export const STATE_FILE = 'studio.json';
13
+ export const LOG_FILE = 'studio.log';
14
+
15
+ /**
16
+ * Resolve `${PROFESSOR_HOME:-~/.professor}` without consulting the real home when one is supplied.
17
+ * @param {{ env?: Record<string,string|undefined>, home?: string }} [opts]
18
+ * @returns {string}
19
+ */
20
+ export function professorHome({ env = process.env, home } = {}) {
21
+ return home || env.PROFESSOR_HOME || join(homedir(), '.professor');
22
+ }
23
+
24
+ /**
25
+ * Absolute path of the background state file.
26
+ * @param {{ env?: Record<string,string|undefined>, home?: string }} [opts]
27
+ * @returns {string}
28
+ */
29
+ export function stateFilePath({ env = process.env, home } = {}) {
30
+ return join(professorHome({ env, home }), STATE_FILE);
31
+ }
32
+
33
+ /**
34
+ * Absolute path of the background log file used by the LaunchAgent.
35
+ * @param {{ env?: Record<string,string|undefined>, home?: string }} [opts]
36
+ * @returns {string}
37
+ */
38
+ export function logFilePath({ env = process.env, home } = {}) {
39
+ return join(professorHome({ env, home }), 'logs', LOG_FILE);
40
+ }
41
+
42
+ /** True for the payload Studio's `/api/studio/health` returns; anything else is a foreign app. */
43
+ export function isStudioHealthPayload(body) {
44
+ return Boolean(body) && body.ok === true && typeof body.version === 'string';
45
+ }
46
+
47
+ /** A recorded state is usable only with a numeric pid; anything else is treated as absent. */
48
+ function isStateShape(value) {
49
+ return Boolean(value) && typeof value === 'object' && Number.isInteger(value.pid);
50
+ }
51
+
52
+ /**
53
+ * Write the state file atomically (temp file + rename, mode `0600`).
54
+ * @param {{ pid: number, port: number, apiPort: number, studioUrl: string, apiUrl: string,
55
+ * startedAt: string }} state
56
+ * @param {{ env?: Record<string,string|undefined>, home?: string }} [opts]
57
+ * @returns {string} the file path
58
+ */
59
+ export function writeState(state, { env = process.env, home } = {}) {
60
+ const file = stateFilePath({ env, home });
61
+ mkdirSync(dirname(file), { recursive: true });
62
+ const tmp = `${file}.${process.pid}.tmp`;
63
+ try {
64
+ writeFileSync(tmp, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
65
+ renameSync(tmp, file);
66
+ } catch (err) {
67
+ try {
68
+ rmSync(tmp, { force: true });
69
+ } catch {
70
+ /* ignore */
71
+ }
72
+ throw err;
73
+ }
74
+ return file;
75
+ }
76
+
77
+ /**
78
+ * Read the state file. A missing, malformed or truncated file reads as `null`.
79
+ * @param {{ env?: Record<string,string|undefined>, home?: string }} [opts]
80
+ * @returns {{ pid: number, port: number, apiPort: number, studioUrl: string, apiUrl: string,
81
+ * startedAt: string } | null}
82
+ */
83
+ export function readState({ env = process.env, home } = {}) {
84
+ try {
85
+ const parsed = JSON.parse(readFileSync(stateFilePath({ env, home }), 'utf-8'));
86
+ return isStateShape(parsed) ? parsed : null;
87
+ } catch {
88
+ return null;
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Remove the state file; missing is not an error.
94
+ * @param {{ env?: Record<string,string|undefined>, home?: string }} [opts]
95
+ * @returns {boolean} true when a file was removed
96
+ */
97
+ export function removeState({ env = process.env, home } = {}) {
98
+ try {
99
+ rmSync(stateFilePath({ env, home }), { force: true });
100
+ return true;
101
+ } catch {
102
+ return false;
103
+ }
104
+ }
105
+
106
+ /**
107
+ * True when the pid exists. `EPERM` counts as alive: the process exists but is not ours.
108
+ * @param {number} pid
109
+ * @param {{ kill?: (pid: number, signal?: number) => void }} [opts]
110
+ * @returns {boolean}
111
+ */
112
+ export function isPidAlive(pid, { kill = process.kill } = {}) {
113
+ if (!Number.isInteger(pid) || pid <= 0) return false;
114
+ try {
115
+ kill(pid, 0);
116
+ return true;
117
+ } catch (err) {
118
+ return err?.code === 'EPERM';
119
+ }
120
+ }
121
+
122
+ /**
123
+ * A timeout signal when the runtime has one; `undefined` keeps the probe working on older Nodes.
124
+ */
125
+ function abortTimeout(ms) {
126
+ try {
127
+ return typeof AbortSignal?.timeout === 'function' ? AbortSignal.timeout(ms) : undefined;
128
+ } catch {
129
+ return undefined;
130
+ }
131
+ }
132
+
133
+ /**
134
+ * One `GET /api/studio/health` against the loopback bridge. Any answer that is not a Studio health
135
+ * payload (404, foreign JSON, timeout, refused connection) is `false`; it never throws.
136
+ *
137
+ * @param {{ apiUrl: string, token?: string|null, fetchImpl?: typeof fetch,
138
+ * timeoutMs?: number }} opts
139
+ * @returns {Promise<boolean>}
140
+ */
141
+ export async function probeStudioHealth({
142
+ apiUrl,
143
+ token,
144
+ fetchImpl = globalThis.fetch,
145
+ timeoutMs = 1500,
146
+ } = {}) {
147
+ const headers = token ? { 'X-Studio-Token': token } : {};
148
+ try {
149
+ const res = await fetchImpl(`${apiUrl}/api/studio/health`, {
150
+ headers,
151
+ signal: abortTimeout(timeoutMs),
152
+ });
153
+ if (!res || !res.ok) return false;
154
+ const body = await res.json().catch(() => null);
155
+ return isStudioHealthPayload(body);
156
+ } catch {
157
+ return false;
158
+ }
159
+ }
@@ -5,21 +5,43 @@
5
5
  * 2. starts the local server (`web/server.js`, production, on `--api-port`) with a pairing token
6
6
  * 3. serves `studio/dist` on `--port` (tiny static server, loopback only)
7
7
  * 4. waits for `/api/studio/health`, then opens
8
- * `http://localhost:<port>/#token=…` (plus `&bridge=…` for a non-default API port)
8
+ * `http://127.0.0.1:<port>/#token=…` (plus `&bridge=…` for a non-default API port)
9
9
  * (unless `--no-open`)
10
10
  *
11
+ * Every URL this command polls or prints uses `127.0.0.1`, matching the server bind: `localhost`
12
+ * resolves to `::1` first on macOS, where another app may already be listening.
13
+ *
11
14
  * Inherited `CLAUDECODE` / `CLAUDE_CODE_*` env vars are stripped from the child so a nested agent
12
15
  * CLI (spawned by the bridge) behaves like a normal terminal.
13
16
  */
14
- import { spawn, execSync } from 'child_process';
17
+ import { spawn, execSync, execFileSync } from 'child_process';
15
18
  import { createServer } from 'http';
19
+ import { createServer as createNetServer } from 'net';
16
20
  import { existsSync, statSync, readdirSync } from 'fs';
17
21
  import { dirname, join } from 'path';
22
+ import { homedir } from 'os';
18
23
  import { fileURLToPath } from 'url';
19
24
  import { createRequire } from 'module';
20
25
  import { createStaticHandler } from '../static-server.js';
21
26
  import { requireStudioNode } from '../node-version.js';
22
27
  import * as security from '../../web/security.js';
28
+ import {
29
+ isStudioHealthPayload,
30
+ removeState,
31
+ writeState,
32
+ } from './studio-state.js';
33
+ import {
34
+ LABEL,
35
+ UNSUPPORTED_PLATFORM,
36
+ collectStatus,
37
+ disableAutostart,
38
+ displayPath,
39
+ enableAutostart,
40
+ formatStatus,
41
+ stopStudio,
42
+ } from './studio-autostart.js';
43
+
44
+ export { isStudioHealthPayload };
23
45
 
24
46
  const __dirname = dirname(fileURLToPath(import.meta.url));
25
47
  export const REPO_ROOT = join(__dirname, '..', '..');
@@ -28,6 +50,19 @@ export const DEFAULT_PORT = 5180;
28
50
  export const DEFAULT_API_PORT = 3000;
29
51
  export const HEALTH_PATH = '/api/studio/health';
30
52
 
53
+ /** Hosts probed for a busy port: the `127.0.0.1` bind plus the IPv6 loopback `localhost` may use. */
54
+ export const LOOPBACK_HOSTS = ['127.0.0.1', '::1'];
55
+
56
+ /** Base URL the CLI polls and prints — the IPv4 literal, never `localhost`. */
57
+ export function loopbackUrl(port) {
58
+ return `http://127.0.0.1:${port}`;
59
+ }
60
+
61
+ /** Rewrite an `http://localhost…` URL to the IPv4 loopback literal. */
62
+ export function toLoopbackUrl(url) {
63
+ return String(url).replace(/^http:\/\/localhost(?=[:/]|$)/, 'http://127.0.0.1');
64
+ }
65
+
31
66
  /**
32
67
  * Usage text for `studio --help`. Kept as a plain string so the CLI can print it without starting
33
68
  * or binding anything.
@@ -41,6 +76,11 @@ Options:
41
76
  --port <n> Studio port (default ${DEFAULT_PORT})
42
77
  --api-port <n> Local bridge server port (default ${DEFAULT_API_PORT})
43
78
  --no-open Do not open the browser
79
+ --strict-port Fail instead of picking another port when one is busy
80
+ --autostart on Start Studio at login (macOS, global install only)
81
+ --autostart off Remove the login item and stop starting Studio at login
82
+ --status Show whether Studio is running, its URLs, autostart and log path
83
+ --stop Stop the running Studio (never another app's process)
44
84
  --rotate-token Generate a new pairing token and exit
45
85
  -h, --help Show this help and exit
46
86
  `.trim();
@@ -52,9 +92,17 @@ export function wantsHelp(args = []) {
52
92
 
53
93
 
54
94
  /**
55
- * Parse `--port`, `--api-port`, `--no-open` (both `--flag value` and `--flag=value` forms).
95
+ * Parse `--port`, `--api-port`, `--no-open`, `--strict-port`, `--status`, `--stop` and
96
+ * `--autostart on|off` (both `--flag value` and `--flag=value` forms).
97
+ * `portExplicit` / `apiPortExplicit` say whether the value came from a valid flag, which decides
98
+ * whether a busy port is an error or an auto-pick.
99
+ * `autostart` is `'on'`, `'off'` or `'invalid'` when the flag was given without a usable value;
100
+ * `null` means the flag was not used.
101
+ *
56
102
  * @param {string[]} [args]
57
- * @returns {{ port: number, apiPort: number, open: boolean, rotateToken: boolean }}
103
+ * @returns {{ port: number, apiPort: number, portExplicit: boolean, apiPortExplicit: boolean,
104
+ * open: boolean, rotateToken: boolean, strictPort: boolean, status: boolean,
105
+ * stop: boolean, autostart: 'on'|'off'|'invalid'|null }}
58
106
  */
59
107
  export function parseStudioArgs(args = []) {
60
108
  const valueOf = (name, fallback) => {
@@ -64,15 +112,32 @@ export function parseStudioArgs(args = []) {
64
112
  if (i !== -1 && args[i + 1] && !args[i + 1].startsWith('--')) return args[i + 1];
65
113
  return fallback;
66
114
  };
67
- const asPort = (raw, fallback) => {
68
- const n = Number.parseInt(raw, 10);
69
- return Number.isInteger(n) && n > 0 && n < 65536 ? n : fallback;
115
+ const readPort = (name, fallback) => {
116
+ const raw = valueOf(name, null);
117
+ const parsed = Number.parseInt(raw, 10);
118
+ const valid = Number.isInteger(parsed) && parsed > 0 && parsed < 65536;
119
+ return { value: valid ? parsed : fallback, explicit: raw !== null && valid };
70
120
  };
121
+ const port = readPort('--port', DEFAULT_PORT);
122
+ const apiPort = readPort('--api-port', DEFAULT_API_PORT);
123
+ const wantsAutostart = args.some((a) => a === '--autostart' || a.startsWith('--autostart='));
124
+ const autostartValue = valueOf('--autostart', null);
125
+ const autostart = wantsAutostart
126
+ ? autostartValue === 'on' || autostartValue === 'off'
127
+ ? autostartValue
128
+ : 'invalid'
129
+ : null;
71
130
  return {
72
- port: asPort(valueOf('--port', String(DEFAULT_PORT)), DEFAULT_PORT),
73
- apiPort: asPort(valueOf('--api-port', String(DEFAULT_API_PORT)), DEFAULT_API_PORT),
131
+ port: port.value,
132
+ apiPort: apiPort.value,
133
+ portExplicit: port.explicit,
134
+ apiPortExplicit: apiPort.explicit,
74
135
  open: !args.includes('--no-open'),
75
136
  rotateToken: args.includes('--rotate-token'),
137
+ strictPort: args.includes('--strict-port'),
138
+ status: args.includes('--status'),
139
+ stop: args.includes('--stop'),
140
+ autostart,
76
141
  };
77
142
  }
78
143
 
@@ -171,8 +236,8 @@ export function mergeOrigins(existing, port) {
171
236
  * @returns {{ apiUrl: string, studioUrl: string, pairingUrl: string }}
172
237
  */
173
238
  export function buildPairingUrl({ studioPort, apiPort, token }) {
174
- const apiUrl = `http://localhost:${apiPort}`;
175
- const studioUrl = `http://localhost:${studioPort}`;
239
+ const apiUrl = loopbackUrl(apiPort);
240
+ const studioUrl = loopbackUrl(studioPort);
176
241
  let pairingUrl = `${studioUrl}/#token=${encodeURIComponent(token)}`;
177
242
  if (Number(apiPort) !== DEFAULT_API_PORT) {
178
243
  pairingUrl += `&bridge=${encodeURIComponent(apiUrl)}`;
@@ -180,6 +245,87 @@ export function buildPairingUrl({ studioPort, apiPort, token }) {
180
245
  return { apiUrl, studioUrl, pairingUrl };
181
246
  }
182
247
 
248
+ /** `true` when `port` is busy on that family, `false` when free or the family is unavailable. */
249
+ function probeBind(port, host) {
250
+ return new Promise((resolve) => {
251
+ const server = createNetServer();
252
+ server.once('error', (err) => resolve(err?.code === 'EADDRINUSE'));
253
+ server.listen({ port, host, exclusive: true }, () => {
254
+ server.close(() => resolve(false));
255
+ });
256
+ });
257
+ }
258
+
259
+ /**
260
+ * True when something already listens on `port` on any loopback family. An IPv4-only listener
261
+ * leaves `::1:<port>` free and vice versa, so both families have to be probed.
262
+ *
263
+ * @param {number|string} port
264
+ * @param {{ hosts?: string[], probe?: (port: number, host: string) => Promise<boolean> }} [opts]
265
+ * @returns {Promise<boolean>}
266
+ */
267
+ export async function isPortBusy(port, { hosts = LOOPBACK_HOSTS, probe = probeBind } = {}) {
268
+ const states = await Promise.all(hosts.map((host) => probe(Number(port), host)));
269
+ return states.some(Boolean);
270
+ }
271
+
272
+ /**
273
+ * First port at or above `from` that is free on every loopback family.
274
+ *
275
+ * @param {number} from
276
+ * @param {{ hosts?: string[], maxTries?: number,
277
+ * isBusy?: (port: number, opts?: object) => Promise<boolean> }} [opts]
278
+ * @returns {Promise<number>}
279
+ */
280
+ export async function findFreePort(
281
+ from,
282
+ { hosts = LOOPBACK_HOSTS, maxTries = 25, isBusy = isPortBusy } = {}
283
+ ) {
284
+ const start = Number(from);
285
+ for (let port = start; port < start + maxTries && port < 65536; port += 1) {
286
+ if (!(await isBusy(port, { hosts }))) return port;
287
+ }
288
+ throw new Error(`No free loopback port between ${start} and ${start + maxTries - 1}.`);
289
+ }
290
+
291
+ /**
292
+ * Keep both ports usable before anything binds: a busy default port moves to the next free one,
293
+ * while a busy port the user pinned with a flag is a hard error. `strict` removes the auto-pick
294
+ * entirely, so a LaunchAgent start never changes the URL it was paired on.
295
+ *
296
+ * @param {{ port: number, apiPort: number, portExplicit?: boolean, apiPortExplicit?: boolean,
297
+ * strict?: boolean,
298
+ * isBusy?: (port: number) => Promise<boolean>,
299
+ * findFree?: (from: number) => Promise<number> }} opts
300
+ * @returns {Promise<{ port: number, apiPort: number, notices: string[] }>}
301
+ */
302
+ export async function resolveStudioPorts({
303
+ port,
304
+ apiPort,
305
+ portExplicit = false,
306
+ apiPortExplicit = false,
307
+ strict = false,
308
+ isBusy = (value) => isPortBusy(value),
309
+ findFree = (from) => findFreePort(from),
310
+ } = {}) {
311
+ const notices = [];
312
+ const pick = async (value, explicit, flag, label) => {
313
+ if (!(await isBusy(value))) return value;
314
+ if (explicit || strict) {
315
+ throw new Error(
316
+ `Port ${value} is already in use on loopback (127.0.0.1 or ::1). ` +
317
+ `Choose another port: --${flag} <free port>.`
318
+ );
319
+ }
320
+ const next = await findFree(value + 1);
321
+ notices.push(`Port ${value} is in use; using ${next} for ${label} instead.`);
322
+ return next;
323
+ };
324
+ const resolvedApiPort = await pick(apiPort, apiPortExplicit, 'api-port', 'the local server');
325
+ const resolvedPort = await pick(port, portExplicit, 'port', 'Studio');
326
+ return { port: resolvedPort, apiPort: resolvedApiPort, notices };
327
+ }
328
+
183
329
  /**
184
330
  * Resolve the pairing token through the shared helper (D-01/D-02). `repoRoot` is still accepted for
185
331
  * tests but only supplies the one-time legacy `web/.studio-token` migration path; the token itself
@@ -198,6 +344,7 @@ export function resolveStudioToken({ repoRoot = REPO_ROOT, env = process.env, ho
198
344
 
199
345
  /**
200
346
  * Poll `/api/studio/health` until it answers 200 with the pairing token.
347
+ * A 404 or a JSON body that is not a Studio health payload means another app owns the port.
201
348
  * @param {{ url: string, token: string, timeoutMs?: number, intervalMs?: number, fetchImpl?: typeof fetch }} opts
202
349
  */
203
350
  export async function waitForHealth({
@@ -209,17 +356,36 @@ export async function waitForHealth({
209
356
  }) {
210
357
  const deadline = Date.now() + timeoutMs;
211
358
  let lastError = 'no response';
359
+ let foreign = false;
212
360
  while (Date.now() < deadline) {
213
361
  try {
214
362
  const res = await fetchImpl(url, { headers: { 'X-Studio-Token': token } });
215
- if (res.ok) return await res.json().catch(() => ({ ok: true }));
216
- lastError = `HTTP ${res.status}`;
363
+ if (res.ok) {
364
+ const body = await res.json().catch(() => null);
365
+ if (isStudioHealthPayload(body)) return body;
366
+ foreign = true;
367
+ lastError = 'an answer that is not Studio';
368
+ } else {
369
+ if (res.status === 404) foreign = true;
370
+ lastError = `HTTP ${res.status}`;
371
+ }
217
372
  } catch (err) {
218
373
  lastError = err.message;
219
374
  }
220
375
  await new Promise((r) => setTimeout(r, intervalMs));
221
376
  }
222
- throw new Error(`Studio server did not become healthy at ${url} (${lastError})`);
377
+ const port = (() => {
378
+ try {
379
+ return new URL(url).port;
380
+ } catch {
381
+ return '';
382
+ }
383
+ })();
384
+ const hint =
385
+ foreign && port
386
+ ? ` Another app is answering on port ${port}; start Studio with --api-port <free port>.`
387
+ : '';
388
+ throw new Error(`Studio server did not become healthy at ${url} (${lastError}).${hint}`);
223
389
  }
224
390
 
225
391
  /**
@@ -259,7 +425,7 @@ export async function createStudioStaticServer({
259
425
  return {
260
426
  server,
261
427
  port: actualPort,
262
- url: `http://localhost:${actualPort}`,
428
+ url: loopbackUrl(actualPort),
263
429
  close: () => new Promise((done) => server.close(() => done())),
264
430
  };
265
431
  }
@@ -337,10 +503,131 @@ function ensureBuild({ studioDir, spinner }) {
337
503
  }
338
504
  }
339
505
 
506
+ /**
507
+ * `--autostart on|off`, `--status` and `--stop`. None of them starts the Studio server, so they run
508
+ * before the Node 22.5 gate and never import `ora`.
509
+ *
510
+ * @param {{ parsed: object, ctx?: object, style?: Function, repoRoot: string }} opts
511
+ * @returns {Promise<number>} process exit code
512
+ */
513
+ async function runBackground({ parsed, ctx = {}, style, repoRoot }) {
514
+ const {
515
+ env = process.env,
516
+ userHome,
517
+ platform = process.platform,
518
+ exec = execFileSync,
519
+ kill = process.kill,
520
+ fetchImpl = globalThis.fetch,
521
+ } = ctx;
522
+ const log = (line) => console.log(line);
523
+ const fail = (line) => console.error(style('error', line));
524
+ const show = (file) => displayPath(file, { home: userHome || homedir() });
525
+
526
+ if (parsed.autostart === 'invalid') {
527
+ fail('--autostart needs a value: on or off.');
528
+ return 1;
529
+ }
530
+ if (parsed.autostart && (parsed.status || parsed.stop)) {
531
+ fail('Use --autostart, --status or --stop one at a time.');
532
+ return 1;
533
+ }
534
+ if (parsed.autostart && platform !== 'darwin') {
535
+ fail(UNSUPPORTED_PLATFORM);
536
+ return 1;
537
+ }
538
+
539
+ if (parsed.autostart === 'on') {
540
+ try {
541
+ const ports = await resolveStudioPorts({
542
+ port: parsed.port,
543
+ apiPort: parsed.apiPort,
544
+ portExplicit: parsed.portExplicit,
545
+ apiPortExplicit: parsed.apiPortExplicit,
546
+ strict: true,
547
+ isBusy: ctx.isPortBusy,
548
+ findFree: ctx.findFreePort,
549
+ });
550
+ const { logPath } = enableAutostart({
551
+ port: ports.port,
552
+ apiPort: ports.apiPort,
553
+ cliPath: join(repoRoot, 'bin', 'cli.js'),
554
+ execPath: ctx.execPath,
555
+ env,
556
+ userHome,
557
+ platform,
558
+ exec,
559
+ uid: ctx.uid,
560
+ });
561
+ log(style('success', `Studio will start at login (${LABEL}).`));
562
+ log(` Studio URL: ${loopbackUrl(ports.port)}`);
563
+ log(` Log: ${show(logPath)}`);
564
+ return 0;
565
+ } catch (err) {
566
+ fail(err.message);
567
+ return 1;
568
+ }
569
+ }
570
+
571
+ if (parsed.autostart === 'off') {
572
+ try {
573
+ const { plistPath, removed } = disableAutostart({ userHome, platform, exec, uid: ctx.uid });
574
+ log(
575
+ removed
576
+ ? `Studio will no longer start at login (removed ${show(plistPath)}).`
577
+ : 'Studio auto-start was already off.'
578
+ );
579
+ return 0;
580
+ } catch (err) {
581
+ fail(err.message);
582
+ return 1;
583
+ }
584
+ }
585
+
586
+ if (parsed.status) {
587
+ const report = await collectStatus({ env, userHome, kill, fetchImpl });
588
+ const pairingUrl =
589
+ report.token && report.state?.port && report.state?.apiPort
590
+ ? buildPairingUrl({
591
+ studioPort: report.state.port,
592
+ apiPort: report.state.apiPort,
593
+ token: report.token,
594
+ }).pairingUrl
595
+ : null;
596
+ log(formatStatus({ ...report, pairingUrl, userHome: userHome || homedir() }));
597
+ return report.status === 'running' ? 0 : 1;
598
+ }
599
+
600
+ if (parsed.stop) {
601
+ const result = await stopStudio({ env, userHome, platform, exec, uid: ctx.uid, kill });
602
+ if (result.refused) {
603
+ fail(`Refusing to stop pid ${result.pid}: it is not a Studio process.`);
604
+ return 1;
605
+ }
606
+ if (result.stopped) {
607
+ log(
608
+ result.via === 'launchctl'
609
+ ? 'Stopped Studio (launchd agent).'
610
+ : `Stopped Studio (pid ${result.pid}).`
611
+ );
612
+ return 0;
613
+ }
614
+ if (result.reason === 'stale') {
615
+ log(`Studio is not running (removed a stale studio.json for pid ${result.pid}).`);
616
+ } else {
617
+ log('Studio is not running.');
618
+ }
619
+ return 0;
620
+ }
621
+
622
+ return 1;
623
+ }
624
+
340
625
  /**
341
626
  * @param {{ args?: string[], cwd?: string, chalk?: object, style?: Function, repoRoot?: string,
342
627
  * ora?: Function, spawnImpl?: typeof spawn, fetchImpl?: typeof fetch,
343
- * openImpl?: typeof openBrowser, nodeVersion?: string }} [ctx]
628
+ * openImpl?: typeof openBrowser, nodeVersion?: string,
629
+ * isPortBusy?: (port: number) => Promise<boolean>,
630
+ * findFreePort?: (from: number) => Promise<number> }} [ctx]
344
631
  */
345
632
  export async function run(ctx = {}) {
346
633
  const {
@@ -358,10 +645,16 @@ export async function run(ctx = {}) {
358
645
  return 0;
359
646
  }
360
647
 
648
+ const parsed = parseStudioArgs(args);
649
+ if (parsed.autostart || parsed.status || parsed.stop) {
650
+ return runBackground({ parsed, ctx, style, repoRoot });
651
+ }
652
+
361
653
  requireStudioNode(ctx.nodeVersion);
362
654
 
363
655
  const ora = ctx.ora || (await import('ora')).default;
364
- const { port, apiPort, open, rotateToken } = parseStudioArgs(args);
656
+ const { open, rotateToken } = parsed;
657
+ let { port, apiPort } = parsed;
365
658
 
366
659
  const hasStartRequest = args.some((arg) => arg !== '--rotate-token');
367
660
  let rotatedToken = null;
@@ -394,6 +687,30 @@ export async function run(ctx = {}) {
394
687
  throw new Error(`Local server not found at ${serverEntry}. Is the package installed fully?`);
395
688
  }
396
689
 
690
+ const portOptions = {
691
+ port,
692
+ apiPort,
693
+ portExplicit: parsed.portExplicit,
694
+ apiPortExplicit: parsed.apiPortExplicit,
695
+ strict: parsed.strictPort,
696
+ isBusy: ctx.isPortBusy,
697
+ findFree: ctx.findFreePort,
698
+ };
699
+ let ports;
700
+ try {
701
+ ports = await resolveStudioPorts(portOptions);
702
+ } catch (err) {
703
+ // The LaunchAgent run has no terminal: one readable line in the log beats a stack trace.
704
+ if (!parsed.strictPort) throw err;
705
+ console.error(style('error', err.message));
706
+ return 1;
707
+ }
708
+ port = ports.port;
709
+ apiPort = ports.apiPort;
710
+ for (const notice of ports.notices) {
711
+ console.log(style('warning', notice));
712
+ }
713
+
397
714
  console.log('');
398
715
  const setupSpinner = ora('Preparing local platform…');
399
716
  // `express`/`cors` are root dependencies, so the web server runs without a nested install. This
@@ -437,6 +754,7 @@ export async function run(ctx = {}) {
437
754
  const shutdown = async (code = 0) => {
438
755
  if (shuttingDown) return;
439
756
  shuttingDown = true;
757
+ try { removeState(); } catch { /* best effort */ }
440
758
  try { apiChild.kill('SIGTERM'); } catch { /* already gone */ }
441
759
  if (staticServer) {
442
760
  try { await staticServer.close(); } catch { /* ignore */ }
@@ -485,6 +803,22 @@ export async function run(ctx = {}) {
485
803
  return done;
486
804
  }
487
805
 
806
+ try {
807
+ writeState(
808
+ {
809
+ pid: process.pid,
810
+ port,
811
+ apiPort,
812
+ studioUrl,
813
+ apiUrl,
814
+ startedAt: new Date().toISOString(),
815
+ },
816
+ { env: process.env }
817
+ );
818
+ } catch (err) {
819
+ console.log(style('warning', `Could not record studio.json: ${err.message}`));
820
+ }
821
+
488
822
  console.log('');
489
823
  console.log(style('success', `Professor Studio is running at ${studioUrl}`));
490
824
  console.log(` Pairing URL: ${pairingUrl}`);