rvlib-shipkit 2.3.0 → 2.5.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 (104) hide show
  1. package/CHANGELOG.md +19 -2
  2. package/package.json +2 -2
  3. package/src/assets/kits/identity-kit.ts +5 -2
  4. package/src/cli/cmd_Commands.ts +30 -5
  5. package/src/cli/cmd_FleetPush.ts +47 -0
  6. package/src/cli/cmd_GlobalProcesses.ts +5 -5
  7. package/src/cli/cmd_Review.ts +52 -11
  8. package/src/cli/cmd_Sound.ts +209 -0
  9. package/src/cli/cmd_Trailer.ts +11 -3
  10. package/src/cli/family-pages.ts +49 -0
  11. package/src/cli/kit/builtins.ts +1 -0
  12. package/src/cli/kit/verb.ts +11 -3
  13. package/src/cli/registry.ts +137 -0
  14. package/src/cli.ts +47 -133
  15. package/src/core/app-icon.ts +10 -6
  16. package/src/core/last-good.ts +217 -0
  17. package/src/core/load-probe.ts +71 -0
  18. package/src/core/reexec.ts +5 -3
  19. package/src/fleet/push-fleet.ts +80 -0
  20. package/src/home/home-routes.ts +12 -2
  21. package/src/home/lib-watch.ts +110 -20
  22. package/src/home/process-rows.ts +4 -2
  23. package/src/home/run-daemon.ts +1 -1
  24. package/src/repo-config.ts +3 -0
  25. package/src/review/format.ts +11 -3
  26. package/src/review/model.ts +66 -5
  27. package/src/review/paste.ts +30 -0
  28. package/src/review/project.ts +20 -4
  29. package/src/review/routes.ts +22 -4
  30. package/src/review/store.ts +15 -6
  31. package/src/review/unit.ts +19 -1
  32. package/src/scaffold/files/agent-index.md +2 -2
  33. package/src/scaffold/project-scaffold.ts +2 -2
  34. package/src/serve/hub-app/activity-st.ts +31 -0
  35. package/src/serve/hub-app/activity-view.tsx +9 -0
  36. package/src/serve/hub-app/activity.css +6 -0
  37. package/src/serve/hub-app/admin-svc.ts +1 -1
  38. package/src/serve/hub-app/admin-view.tsx +1 -1
  39. package/src/serve/hub-app/hash-route.ts +12 -4
  40. package/src/serve/hub-app/home-st.ts +7 -1
  41. package/src/serve/hub-app/home-strip.tsx +3 -1
  42. package/src/serve/hub-app/hub.css +38 -1
  43. package/src/serve/hub-app/icons.tsx +6 -0
  44. package/src/serve/hub-app/layout-st.ts +2 -2
  45. package/src/serve/hub-app/machine-st.ts +1 -1
  46. package/src/serve/hub-app/md.ts +32 -0
  47. package/src/serve/hub-app/project-list.css +9 -7
  48. package/src/serve/hub-app/project-list.tsx +5 -2
  49. package/src/serve/hub-app/projects-svc.ts +3 -2
  50. package/src/serve/hub-app/repo-st.ts +22 -7
  51. package/src/serve/hub-app/review/clip-st.ts +57 -7
  52. package/src/serve/hub-app/review/kind-view.tsx +78 -9
  53. package/src/serve/hub-app/review/review-answer.tsx +67 -58
  54. package/src/serve/hub-app/review/review-page.tsx +17 -15
  55. package/src/serve/hub-app/review/review-st.ts +191 -20
  56. package/src/serve/hub-app/review/review-views.tsx +154 -9
  57. package/src/serve/hub-app/review.css +73 -13
  58. package/src/serve/hub-app/wiki-st.ts +190 -0
  59. package/src/serve/hub-app/wikis-view.tsx +134 -5
  60. package/src/serve/hub-runtime.ts +4 -4
  61. package/src/serve/project-gutter-route.ts +25 -1
  62. package/src/serve/project-gutter.ts +1 -1
  63. package/src/serve/run-serve.ts +25 -0
  64. package/src/serve/serve-hub.ts +15 -1
  65. package/src/serve/stale-restart.ts +19 -2
  66. package/src/shot/client.ts +17 -5
  67. package/src/shot/shooter.ts +19 -3
  68. package/src/shot/shot-ask.ts +9 -1
  69. package/src/shot/stepped-clip.ts +53 -0
  70. package/src/trailer/adapter.ts +33 -4
  71. package/src/trailer/build.ts +182 -33
  72. package/src/trailer/ffmpeg.ts +21 -1
  73. package/src/trailer/fonts.ts +55 -0
  74. package/src/trailer/footage.ts +4 -4
  75. package/src/trailer/grammar.ts +429 -67
  76. package/src/trailer/graph.ts +130 -56
  77. package/src/trailer/look.ts +3 -0
  78. package/src/trailer/motion.ts +207 -0
  79. package/src/trailer/plan.ts +2 -2
  80. package/src/trailer/prune.ts +13 -2
  81. package/src/trailer/score/compose.ts +18 -2
  82. package/src/trailer/score/gm-render.ts +43 -0
  83. package/src/trailer/score/gm.ts +90 -0
  84. package/src/trailer/score/midi.ts +143 -0
  85. package/src/trailer/score/pattern.ts +13 -6
  86. package/src/trailer/score/score.ts +28 -14
  87. package/src/trailer/score/sound.ts +12 -1
  88. package/src/trailer/score/synth.ts +24 -2
  89. package/src/trailer/sound/audio.ts +148 -0
  90. package/src/trailer/sound/brand.ts +42 -0
  91. package/src/trailer/sound/comfy.ts +38 -0
  92. package/src/trailer/sound/eleven.ts +72 -0
  93. package/src/trailer/sound/fluid.ts +71 -0
  94. package/src/trailer/sound/gen.ts +139 -0
  95. package/src/trailer/sound/index.ts +60 -0
  96. package/src/trailer/sound/presets.ts +69 -0
  97. package/src/trailer/sound/run.ts +189 -0
  98. package/src/trailer/titles.ts +451 -235
  99. package/src/trailer/url.ts +18 -3
  100. package/src/trailer/vocab.ts +12 -1
  101. package/src/trailer/words.ts +29 -8
  102. package/src/wiki/graph-mount.ts +3 -0
  103. package/src/wiki/graph-page.ts +45 -9
  104. package/src/wiki/graph-pages.ts +128 -0
@@ -118,7 +118,7 @@
118
118
  .rv-word{margin-top:var(--s2)}
119
119
  .rv-raised{padding:var(--s2) var(--s3);border:1px solid var(--line);border-left:4px solid var(--warn);border-radius:4px;background:var(--panel)}
120
120
  .rv-raisedimg{display:block;max-width:100%;max-height:320px;margin-top:var(--s2);border-radius:4px}
121
- .rv-pairgrid{margin:0 auto;display:grid;gap:var(--s2)}
121
+ .rv-pairgrid{display:grid;gap:var(--s2)}
122
122
  .rv-cell{display:flex;flex-direction:column;align-items:center;gap:var(--s0);min-width:0}
123
123
  .rv-frame{position:relative;width:100%;max-width:100%;border-radius:4px;border:1px solid var(--line);overflow:hidden;
124
124
  background:repeating-conic-gradient(var(--chk1) 0 25%,var(--chk2) 0 50%) 0 0/16px 16px}
@@ -148,7 +148,7 @@
148
148
  .rv-tile{display:flex;flex-direction:column;gap:var(--s1);padding:var(--s1);border-radius:6px;border:2px solid var(--line)}
149
149
  .rv-tile[class*=' re-']{border-color:var(--tone);background:color-mix(in srgb,var(--tone) 8%,transparent)}
150
150
  .rv-tile.big{width:100%}
151
- .rv-tileimg{border-radius:4px;cursor:zoom-in;margin:0 auto;width:100%;background:repeating-conic-gradient(var(--chk1) 0 25%,var(--chk2) 0 50%) 0 0/12px 12px}
151
+ .rv-tileimg{border-radius:4px;cursor:zoom-in;width:100%;background:repeating-conic-gradient(var(--chk1) 0 25%,var(--chk2) 0 50%) 0 0/12px 12px}
152
152
  .rv-tile.big .rv-tileimg{cursor:zoom-out}
153
153
  .rv-tileimg img{display:block;width:100%;height:100%;object-fit:contain}
154
154
  .rv-tilelabel{font-size:11px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
@@ -159,19 +159,64 @@
159
159
  .rv-sound button{padding:0 var(--s1);line-height:22px}
160
160
  .rv-sound input{width:72px}
161
161
  .rv-phonepair{display:flex;flex-direction:column;gap:var(--s2)}
162
+ /* options: one thin row a view, one below the other; the row playing or last touched has the line, the picked one its tone */
163
+ .rv-opts{display:flex;flex-direction:column;gap:var(--s1)}
164
+ .rv-opt{scroll-margin-top:8px;padding:var(--s1) var(--s2);border-radius:6px;border:1px solid var(--line);border-left:4px solid transparent;background:var(--panel)}
165
+ .rv-opt.active{border-left-color:var(--accent)}
166
+ .rv-opt[class*=' re-']{border-left-color:var(--tone)}
167
+ .rv-opt.picked{--tone:var(--ok);border-color:var(--tone);border-left-color:var(--tone);background:color-mix(in srgb,var(--tone) 9%,var(--panel))}
168
+ .rv-optrow{display:flex;flex-wrap:wrap;gap:var(--s1) var(--s2);align-items:center;min-height:32px}
169
+ .rv-optn{flex:none;width:16px;text-align:right;font:600 12px ui-monospace,Menlo,monospace;color:var(--dim)}
170
+ .rv-optplay{flex:none;width:32px;height:28px;padding:0;font-size:13px}
171
+ .rv-optplay.on{color:var(--bg);background:var(--accent);border-color:var(--accent)}
172
+ .rv-optlabel{flex:0 1 auto;min-width:48px;max-width:30%;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
173
+ .rv-optthumb{flex:none;height:32px;max-width:72px;border-radius:3px;overflow:hidden;cursor:zoom-in;border:1px solid var(--line);background:var(--panel2)}
174
+ .rv-optthumb.img{width:48px}
175
+ .rv-optthumb img{display:block;width:100%;height:100%;object-fit:cover}
176
+ .rv-strip{position:relative;flex:1 1 140px;min-width:120px;height:28px;border-radius:3px;cursor:pointer;touch-action:none;background:var(--panel2);border:1px solid var(--line);overflow:hidden}
177
+ .rv-stripdone{position:absolute;inset:0 auto 0 0;background:var(--wash)}
178
+ /* a waveform is a mask filled with tokens: --dim to play (5.0:1 on --panel2 light, 5.3:1 dark), --accent played
179
+ (4.9:1 both) */
180
+ .rv-stripwave{position:absolute;inset:0;background:var(--dim)}
181
+ .rv-stripwave.done{background:var(--accent)}
182
+ .rv-stripwave.full{background:var(--accent)}
183
+ .rv-wavebox{position:relative;width:100%;border-radius:4px;background:var(--panel2)}
184
+ .rv-striphead{position:absolute;top:0;bottom:0;width:2px;transform:translateX(-1px);background:var(--accent)}
185
+ .rv-opttime{flex:none;font-size:11px;color:var(--dim)}
186
+ .rv-optacts{flex:none;display:inline-flex;gap:var(--s1);align-items:center;margin-left:auto}
187
+ .rv-optacts > button{padding:0 var(--s1);line-height:22px}
188
+ .rv-optacts > button.on{border-color:var(--accent)}
189
+ .rv-stars{display:inline-flex}
190
+ .rv-stars button{border:0;background:none;padding:0;font-size:16px;line-height:22px;color:var(--dim)}
191
+ .rv-stars button.on{color:var(--warn)}
192
+ .rv-pick{font-weight:700;padding:0 var(--s3)!important;color:var(--ok);border-color:color-mix(in srgb,var(--ok) 55%,var(--line))}
193
+ .rv-pick.on{color:var(--bg);background:var(--ok);border-color:var(--ok)}
194
+ .rv-optnote{margin:var(--s0) 0 0 var(--s4);font-size:12.5px;color:var(--ink)}
195
+ .rv-opt .rv-word{margin:var(--s1) 0 0 var(--s4);width:calc(100% - var(--s4))}
196
+ .rv-optbody{margin-top:var(--s2)}
162
197
 
163
198
  /* ── the answer ── */
164
199
  .rv-form{display:flex;flex-direction:column;gap:var(--s2);padding:var(--s3)}
165
- .rv-status{padding:var(--s1) var(--s3);border-radius:4px;border:1px solid color-mix(in srgb,var(--tone) 45%,var(--line));border-left:4px solid var(--tone);background:color-mix(in srgb,var(--tone) 10%,var(--panel))}
166
- .rv-statushead{font-weight:800;color:var(--tone);display:flex;gap:var(--s1);align-items:center;flex-wrap:wrap}
167
- .rv-title{margin:0;font-size:17px;line-height:1.3}
168
- .rv-what{font-size:13px}
169
- .rv-what ul{margin:var(--s1) 0;padding-left:var(--s4)}
170
- .rv-what p{margin:var(--s1) 0}
200
+ .rv-title{margin:0;font-size:16px;line-height:1.3;color:var(--ink)}
201
+ /* the card's header: the title, one tight line of state, then what the agent says at full ink; --dim holds 5.6:1 or
202
+ more on --bg and --panel in both themes */
203
+ .rv-head{display:flex;flex-direction:column;gap:var(--s1)}
204
+ .rv-meta{display:flex;flex-wrap:wrap;gap:var(--s1) var(--s2);align-items:center;font-size:12px;color:var(--dim);line-height:18px}
205
+ .rv-meta .rv-badge{font-size:10px;line-height:18px;max-width:none}
206
+ .rv-copyid{display:inline-flex;align-items:center;padding:var(--s0);color:var(--dim)}
207
+ .rv-copyid:hover{color:var(--ink)}
208
+ .rv-toast{position:fixed;left:50%;bottom:var(--s4);transform:translateX(-50%);z-index:40;max-width:calc(100% - 2 * var(--s4));padding:var(--s1) var(--s3);border-radius:6px;border:1px solid var(--line);background:var(--panel);color:var(--ink);font-size:12.5px;box-shadow:0 6px 20px var(--shadow)}
209
+ .rv-what{font-size:13.5px;line-height:1.45;color:var(--ink)}
210
+ .rv-what ul{margin:var(--s0) 0;padding-left:var(--s4)}
211
+ .rv-what p{margin:var(--s0) 0}
212
+ .rv-why{color:var(--dim);font-size:12px}
213
+ .rv-headverdicts{display:flex;flex-wrap:wrap;gap:var(--s1);align-items:center;margin-top:var(--s1)}
214
+ .rv-headverdicts .rv-verdict{padding:0 var(--s2);line-height:24px;font-size:12.5px}
215
+ .rv-head .rv-comment{margin-top:var(--s1);width:100%}
171
216
  .rv-keeps{color:var(--ok);font-size:13px}
172
217
  .rv-facts{display:flex;flex-direction:column}
173
- .rv-fact{display:flex;gap:var(--s2);align-items:center;min-height:24px;font-size:12.5px}
174
- .rv-factl{width:64px;flex:none;color:var(--dim);font-size:11px}
218
+ .rv-fact{display:flex;gap:var(--s2);align-items:center;min-height:22px;font-size:12.5px}
219
+ .rv-factl{width:64px;flex:none;color:var(--dim);font-size:12px}
175
220
  .rv-factv{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
176
221
  .rv-linked{padding:var(--s1) var(--s2);border:1px solid var(--line);border-radius:4px;background:var(--panel)}
177
222
  .rv-linkedrow{display:flex;gap:var(--s2);align-items:center;font-size:12px}
@@ -184,9 +229,6 @@
184
229
  .rv-outbadge.rect{--tone:var(--warn)}
185
230
  .rv-outbadge.rect.on{--tone:var(--blue)}
186
231
  .rv-x{flex:none;padding:0 var(--s1);border:0}
187
- .rv-label{font-size:12px;font-weight:600;margin-top:var(--s1)}
188
- .rv-listenrow{display:flex;gap:var(--s2);align-items:center;font-size:11px}
189
- .rv-verdicts{display:flex;flex-direction:column;gap:var(--s1)}
190
232
  .rv-verdict{text-align:left;padding:var(--s1) var(--s3);font-size:13px;border-color:color-mix(in srgb,var(--tone) 60%,var(--line))}
191
233
  .rv-verdict .rv-vglyph{color:var(--tone);font-weight:800}
192
234
  .rv-verdict.chosen{background:var(--tone);color:var(--bg);border-color:var(--tone)}
@@ -208,6 +250,17 @@
208
250
  .rv-verdictbar{flex:none;display:flex;gap:var(--s1);padding:var(--s2);border-top:1px solid var(--line);background:var(--panel)}
209
251
  .rv-verdictbar .rv-verdict{flex:1;text-align:center;font-size:14px;padding:var(--s2) var(--s1);color:var(--tone)}
210
252
  .rv-verdictbar .rv-verdict.chosen{color:var(--bg)}
253
+ .rv-phone .rv-views{gap:var(--s3)}
254
+ .rv-phone .rv-toast{top:var(--s4);bottom:auto}
255
+ /* a row on a phone: ▶, the label and the time on one line, the strip and the acts under them */
256
+ .rv-phone .rv-optrow{flex-wrap:wrap;row-gap:var(--s1)}
257
+ .rv-phone .rv-optlabel{flex:1;max-width:none}
258
+ .rv-phone .rv-strip{order:5;flex:1 1 100%;height:32px}
259
+ .rv-phone .rv-optthumb{order:4}
260
+ .rv-phone .rv-optacts{order:6;flex:1 1 100%;justify-content:space-between}
261
+ .rv-phone .rv-stars button{font-size:22px;padding:0 var(--s1)}
262
+ .rv-phone .rv-optplay{width:40px;height:34px}
263
+ .rv-phone .rv-optnote,.rv-phone .rv-opt .rv-word{margin-left:0;width:100%}
211
264
 
212
265
  /* ── the trailer page ── */
213
266
  .tr-cuts{display:flex;flex-direction:column;gap:var(--s2);padding:var(--s3)}
@@ -323,6 +376,13 @@
323
376
  .rv-boardrow .rv-linkedtitle{display:block}
324
377
  .rv-kindview{display:flex;flex-direction:column;gap:var(--s2);margin:var(--s2) 0}
325
378
  .rv-kindtext{font:12px/1.5 ui-monospace,Menlo,monospace;background:var(--panel);border:1px solid var(--line);border-radius:3px;padding:var(--s2) var(--s3);margin:0;white-space:pre-wrap;overflow-wrap:anywhere}
379
+ .rv-part{border:1px solid var(--line);border-radius:4px;padding:var(--s2) var(--s3);margin-top:var(--s2);background:var(--panel)}
380
+ .rv-parthead{display:flex;align-items:center;gap:var(--s2);margin-bottom:var(--s2)}
381
+ .rv-particon{width:20px;height:20px;border-radius:5px;flex:none}
382
+ .rv-partbadge{margin-left:auto;font-size:11px;font-weight:700;border:1px solid;border-radius:3px;padding:0 var(--s1)}
383
+ .rv-partbody{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:var(--s3);align-items:start}
384
+ .rv-partbody img,.rv-partbody video{width:100%;max-height:320px;object-fit:contain;border-radius:6px;display:block}
385
+ .rv-parttext{font-size:13px;line-height:1.45;white-space:pre-wrap;overflow-wrap:anywhere}
326
386
  .rv-anim select{font-size:12px}
327
387
  /* a sound view (review-views.tsx SoundBody): its waveform over the player, a row a side on a pair */
328
388
  .rv-sound{display:flex;flex-direction:column;gap:var(--s2)}
@@ -0,0 +1,190 @@
1
+ // the wikis page's state, one per repo: the search bar over the open wiki
2
+ // (its title and full text, graph-pages.ts on the server), the hit under the
3
+ // arrows, and the page open in the preview panel. The graph is an iframe that
4
+ // only draws (wiki/graph-page.ts): a click on a ball arrives as a message,
5
+ // and opening a page answers with a focus message, so the ball and the
6
+ // preview always show the same page.
7
+ import { makeAutoObservable, runInAction } from 'mobx'
8
+ import type { WikiHit, WikiPage } from '../../wiki/graph-pages.ts'
9
+ import type { RepoSt } from './repo-st.ts'
10
+
11
+ const WIDTH_KEY = 'hub:wiki-preview-width'
12
+ const HEIGHT_KEY = 'hub:wiki-preview-height'
13
+ const PLACE_KEY = 'hub:wiki-preview-place'
14
+ export const PREVIEW = { min: 260, max: 1200, def: 460 }
15
+ /** the preview below the graph: its height */
16
+ export const PREVIEW_H = { min: 160, max: 1200, def: 360 }
17
+ /** where the preview sits against the graph, toggled from its own head */
18
+ export const PLACES = ['left', 'right', 'bottom'] as const
19
+ export type PreviewPlace = (typeof PLACES)[number]
20
+ /** the wait after a keystroke before the search asks the server */
21
+ const SEARCH_MS = 120
22
+
23
+ function loadSize(key: string, b: { min: number; max: number; def: number }): number {
24
+ try {
25
+ const n = Number(globalThis.localStorage?.getItem(key))
26
+ return Number.isFinite(n) && n >= b.min && n <= b.max ? n : b.def
27
+ } catch {
28
+ return b.def
29
+ }
30
+ }
31
+
32
+ function loadPlace(): PreviewPlace {
33
+ try {
34
+ const v = globalThis.localStorage?.getItem(PLACE_KEY) ?? ''
35
+ return (PLACES as readonly string[]).includes(v) ? (v as PreviewPlace) : 'left'
36
+ } catch {
37
+ return 'left'
38
+ }
39
+ }
40
+
41
+ function keep(key: string, v: string): void {
42
+ try {
43
+ globalThis.localStorage?.setItem(key, v)
44
+ } catch { /* private window: the choice lasts the tab */ }
45
+ }
46
+
47
+ export class WikiSt {
48
+ /** the open wiki's route, `/graph/<id>`: every read is under it */
49
+ href: string | null = null
50
+ query = ''
51
+ hits: WikiHit[] = []
52
+ /** the hit under the arrows */
53
+ cursor = 0
54
+ /** the dropdown shows */
55
+ listOpen = false
56
+ page: WikiPage | null = null
57
+ error = ''
58
+ width = loadSize(WIDTH_KEY, PREVIEW)
59
+ height = loadSize(HEIGHT_KEY, PREVIEW_H)
60
+ place: PreviewPlace = loadPlace()
61
+ /** the copy button said copied, for a moment */
62
+ copied = false
63
+ private frame: Window | null = null
64
+ private timer: ReturnType<typeof setTimeout> | null = null
65
+ private asked = 0
66
+
67
+ constructor(private readonly st: RepoSt) {
68
+ makeAutoObservable<this, 'st' | 'frame' | 'timer' | 'asked'>(this, { st: false, frame: false, timer: false, asked: false })
69
+ }
70
+
71
+ /** the wiki shown changed: its search starts over, and its index opens */
72
+ bind(href: string | null): void {
73
+ if (href === this.href) return
74
+ this.href = href
75
+ this.query = ''
76
+ this.hits = []
77
+ this.page = null
78
+ this.error = ''
79
+ this.listOpen = false
80
+ if (href != null) void this.open('')
81
+ }
82
+
83
+ /** the graph's iframe, to send it focus and theme */
84
+ setFrame(w: Window | null): void {
85
+ this.frame = w
86
+ }
87
+
88
+ send(msg: { type: 'wiki-graph:focus'; id: string } | { type: 'wiki-graph:theme'; theme: 'dark' | 'light' }): void {
89
+ this.frame?.postMessage(msg, '*')
90
+ }
91
+
92
+ setQuery(q: string): void {
93
+ this.query = q
94
+ this.cursor = 0
95
+ this.listOpen = q.trim() !== ''
96
+ if (this.timer != null) clearTimeout(this.timer)
97
+ if (q.trim() === '') {
98
+ this.hits = []
99
+ return
100
+ }
101
+ this.timer = setTimeout(() => void this.search(q), SEARCH_MS)
102
+ }
103
+
104
+ private async search(q: string): Promise<void> {
105
+ if (this.href == null) return
106
+ const n = ++this.asked
107
+ try {
108
+ const r = await fetch(this.st.url(`${this.href}/search?q=${encodeURIComponent(q)}`))
109
+ if (!r.ok) throw new Error(`${r.status} ${await r.text()}`)
110
+ const hits = (await r.json()) as WikiHit[]
111
+ // an answer to an older query never replaces a newer one
112
+ if (n === this.asked) runInAction(() => { this.hits = hits; this.error = '' })
113
+ } catch (e) {
114
+ if (n === this.asked) runInAction(() => (this.error = `search failed: ${(e as Error).message}`))
115
+ }
116
+ }
117
+
118
+ /** the search input's keys: up and down walk the hits, enter opens one, escape closes the list */
119
+ key(k: string): boolean {
120
+ if (k === 'Escape') {
121
+ if (!this.listOpen) return false
122
+ this.listOpen = false
123
+ return true
124
+ }
125
+ if (this.hits.length === 0) return false
126
+ if (k === 'ArrowDown') this.cursor = Math.min(this.hits.length - 1, this.cursor + 1)
127
+ else if (k === 'ArrowUp') this.cursor = Math.max(0, this.cursor - 1)
128
+ else if (k === 'Enter') void this.open(this.hits[this.cursor]!.id)
129
+ else return false
130
+ if (k !== 'Enter') this.listOpen = true
131
+ return true
132
+ }
133
+
134
+ /** a page opens in the preview, and its ball is focused in the graph; '' = the wiki's index */
135
+ async open(id: string): Promise<void> {
136
+ const href = this.href
137
+ if (href == null) return
138
+ this.listOpen = false
139
+ if (id !== '') this.send({ type: 'wiki-graph:focus', id })
140
+ try {
141
+ const r = await fetch(this.st.url(`${href}/page?node=${encodeURIComponent(id)}`))
142
+ if (!r.ok) throw new Error(`${r.status} ${await r.text()}`)
143
+ const p = (await r.json()) as WikiPage
144
+ // the wiki changed while the page was read: it is not this one's
145
+ if (href !== this.href) return
146
+ runInAction(() => { this.page = p; this.error = '' })
147
+ if (id === '') this.send({ type: 'wiki-graph:focus', id: p.id })
148
+ } catch (e) {
149
+ runInAction(() => (this.error = `${id === '' ? 'the index' : id} would not open: ${(e as Error).message}`))
150
+ }
151
+ }
152
+
153
+ setPlace(p: PreviewPlace): void {
154
+ this.place = p
155
+ keep(PLACE_KEY, p)
156
+ }
157
+
158
+ /** the open page's path on disk, to the clipboard */
159
+ async copyPath(): Promise<void> {
160
+ if (this.page == null) return
161
+ try {
162
+ await navigator.clipboard.writeText(this.page.file)
163
+ runInAction(() => (this.copied = true))
164
+ setTimeout(() => runInAction(() => (this.copied = false)), 1500)
165
+ } catch (e) {
166
+ runInAction(() => (this.error = `the path was not copied: ${(e as Error).message}`))
167
+ }
168
+ }
169
+
170
+ close(): void {
171
+ this.page = null
172
+ }
173
+
174
+ /** a drag of the preview's separator ended at this size: its width beside the graph, its height under it */
175
+ setSize(px: number): void {
176
+ if (this.place === 'bottom') {
177
+ this.height = Math.round(Math.min(PREVIEW_H.max, Math.max(PREVIEW_H.min, px)))
178
+ keep(HEIGHT_KEY, String(this.height))
179
+ } else {
180
+ this.width = Math.round(Math.min(PREVIEW.max, Math.max(PREVIEW.min, px)))
181
+ keep(WIDTH_KEY, String(this.width))
182
+ }
183
+ }
184
+
185
+ /** a message from the graph: a ball was clicked */
186
+ onMessage(data: unknown): void {
187
+ const m = data as { type?: unknown; id?: unknown } | null
188
+ if (m != null && m.type === 'wiki-graph:pick' && typeof m.id === 'string') void this.open(m.id)
189
+ }
190
+ }
@@ -1,7 +1,16 @@
1
1
  // The wikis page: one tab per mounted markdown wiki, one link graph at a
2
2
  // time, embedded from its own /graph/<id> route. A tab is an item of the
3
3
  // wikis collection, so the open wiki is in the url like any other page.
4
+ // Over the graph a search bar (title and full text, a dropdown the arrows
5
+ // walk); a hit, or a click on a ball, opens the page in a resizable panel on
6
+ // the right, whose refs open their page in turn and focus its ball (wiki-st.ts).
4
7
  import { observer } from 'mobx-react-lite'
8
+ import { useEffect, useRef, type MouseEvent } from 'react'
9
+ import { Group, Panel, Separator, type Layout } from 'react-resizable-panels'
10
+ import { renderWikiPage } from './md.ts'
11
+ import { editorHref } from './check-words.ts'
12
+ import { Icon, ICON } from './icons.tsx'
13
+ import { PLACES, PREVIEW, PREVIEW_H, type PreviewPlace, type WikiSt } from './wiki-st.ts'
5
14
  import type { RepoSt } from './repo-st.ts'
6
15
  import type { Resource } from './hub-types.ts'
7
16
 
@@ -33,8 +42,13 @@ export function openWikis(app: RepoSt): void {
33
42
  else app.showCol(WIKIS)
34
43
  }
35
44
 
45
+ const PANEL = { overflow: 'hidden' } as const
46
+
36
47
  export const WikisView = observer(({ app, id }: { app: RepoSt; id: string }) => {
37
48
  const i = app.cols.find(WIKIS, id)
49
+ const w = app.wiki
50
+ const href = i?.href ?? null
51
+ useEffect(() => w.bind(href), [w, href])
38
52
  const tabs = (
39
53
  <span className="seg">
40
54
  {wikiTabs(app).map((t) => <button key={t.id} className={t.on ? 'on' : ''} onClick={t.go}><span className={`tl ${t.state}`}>{t.label}</span></button>)}
@@ -45,13 +59,128 @@ export const WikisView = observer(({ app, id }: { app: RepoSt; id: string }) =>
45
59
  <div className="graphview">
46
60
  <div className="gvhead">
47
61
  {tabs}
62
+ {href == null ? null : <WikiSearch w={w} />}
48
63
  <span className={`gvtext tl ${i.state}`}>{i.text}</span>
49
64
  </div>
50
- {i.href == null ? <div className="ph">this wiki carries no page to embed</div> : (
51
- // the spinner sits under the frame: the graph page paints its
52
- // own background over it the moment it has something to show
53
- <div className="gvframe"><span className="gvwait"><span className="spin" />reading the wiki…</span><iframe src={app.url(i.href)} title={i.title} /></div>
54
- )}
65
+ {w.error !== '' ? <div className="hnote">{w.error}</div> : null}
66
+ {href == null ? <div className="ph">this wiki carries no page to embed</div> : <WikiBody app={app} w={w} href={href} title={i.title} />}
67
+ </div>
68
+ )
69
+ })
70
+
71
+ /** the graph and, once a page is open, its preview on the right; the preview's width is dragged and kept */
72
+ const WikiBody = observer(({ app, w, href, title }: { app: RepoSt; w: WikiSt; href: string; title: string }) => {
73
+ const el = useRef<HTMLDivElement | null>(null)
74
+ const frame = useRef<HTMLIFrameElement | null>(null)
75
+ const theme = app.home.theme.resolved
76
+ // the theme the graph first paints in rides its url; a later change is a message, never a reload
77
+ const first = useRef(theme)
78
+ useEffect(() => w.send({ type: 'wiki-graph:theme', theme }), [w, theme])
79
+ useEffect(() => {
80
+ const on = (ev: MessageEvent): void => {
81
+ if (ev.source != null && ev.source === frame.current?.contentWindow) w.onMessage(ev.data)
82
+ }
83
+ addEventListener('message', on)
84
+ return () => removeEventListener('message', on)
85
+ }, [w])
86
+ const sep = href.includes('?') ? '&' : '?'
87
+ const below = w.place === 'bottom'
88
+ const graph = (
89
+ <Panel key="graph" id="wiki-graph" minSize={below ? 160 : 280} style={PANEL}>
90
+ {/* the spinner sits under the frame: the graph page paints its own background over it the moment it has something to show */}
91
+ <div className="gvframe"><span className="gvwait"><span className="spin" />reading the wiki…</span>
92
+ <iframe ref={(f) => { frame.current = f; w.setFrame(f?.contentWindow ?? null) }} src={`${app.url(href)}${sep}theme=${first.current}`} title={title}
93
+ onLoad={() => {
94
+ w.setFrame(frame.current?.contentWindow ?? null)
95
+ w.send({ type: 'wiki-graph:theme', theme: app.home.theme.resolved })
96
+ if (w.page != null) w.send({ type: 'wiki-graph:focus', id: w.page.id })
97
+ }} />
98
+ </div>
99
+ </Panel>
100
+ )
101
+ const preview = w.page == null ? null : (
102
+ <Panel key="preview" id="wiki-preview" defaultSize={below ? w.height : w.width} minSize={below ? PREVIEW_H.min : PREVIEW.min} maxSize={below ? PREVIEW_H.max : PREVIEW.max}
103
+ groupResizeBehavior="preserve-pixel-size" style={PANEL}>
104
+ <WikiPreview w={w} />
105
+ </Panel>
106
+ )
107
+ const line = preview == null ? null : <Separator key="sep" className={below ? 'rsep hsep' : 'rsep'} />
108
+ return (
109
+ // the group is remade when the preview moves: its panels change order and axis
110
+ <Group key={w.place} className="gvbody" orientation={below ? 'vertical' : 'horizontal'} elementRef={el}
111
+ onLayoutChanged={(layout: Layout, meta) => {
112
+ const pct = layout['wiki-preview']
113
+ const r = el.current?.getBoundingClientRect()
114
+ const whole = below ? r?.height : r?.width
115
+ if (meta.isUserInteraction && pct != null && whole != null && whole > 0) w.setSize((pct / 100) * whole)
116
+ }}>
117
+ {w.place === 'left' ? [preview, line, graph] : [graph, line, preview]}
118
+ </Group>
119
+ )
120
+ })
121
+
122
+ /** the search bar: the hits drop under it, the arrows walk them, enter or a click opens one */
123
+ const WikiSearch = observer(({ w }: { w: WikiSt }) => (
124
+ <span className="wsearch">
125
+ <input type="search" placeholder="search the wiki: a title, a word…" aria-label="search the wiki" value={w.query}
126
+ onChange={(e) => w.setQuery(e.currentTarget.value)}
127
+ onFocus={() => { if (w.hits.length > 0) w.key('ArrowUp') }}
128
+ onBlur={() => w.key('Escape')}
129
+ onKeyDown={(e) => { if (w.key(e.key)) e.preventDefault() }} />
130
+ {w.listOpen && w.query.trim() !== '' ? (
131
+ <div className="wslist" role="listbox">
132
+ {w.hits.length === 0 ? <div className="wsnone">no page matches</div> : w.hits.map((h, n) => (
133
+ // mouse down, not click: the input's blur would close the list first
134
+ <div key={h.id} role="option" aria-selected={n === w.cursor} className={`wshit${n === w.cursor ? ' on' : ''}`}
135
+ onMouseDown={(e) => { e.preventDefault(); void w.open(h.id) }}>
136
+ <div className="wshead"><b>{h.title}</b><span className="wspath">{h.id}</span></div>
137
+ {h.snippet === '' || h.inTitle && h.snippet === '' ? null : <div className="wssnip">{h.snippet}</div>}
138
+ </div>
139
+ ))}
140
+ </div>
141
+ ) : null}
142
+ </span>
143
+ ))
144
+
145
+ const PLACE_ICON: Record<PreviewPlace, keyof typeof ICON> = { left: 'paneLeft', right: 'paneRight', bottom: 'paneBottom' }
146
+
147
+ /** the open page: its title and path, where the panel sits, the editor and
148
+ * the copy, the close; then the markdown, whose refs open their own page */
149
+ const WikiPreview = observer(({ w }: { w: WikiSt }) => {
150
+ const p = w.page!
151
+ const editor = editorHref('', p.file)
152
+ const click = (e: MouseEvent<HTMLDivElement>): void => {
153
+ const t = e.target as HTMLElement
154
+ const a = t.closest('a[data-wiki]')
155
+ if (a != null) {
156
+ e.preventDefault()
157
+ void w.open(a.getAttribute('data-wiki')!)
158
+ return
159
+ }
160
+ // a ref in a code span opens its page by its text
161
+ const code = t.closest('code')
162
+ const to = code == null ? undefined : p.links[code.textContent ?? '']
163
+ if (to != null) void w.open(to)
164
+ }
165
+ return (
166
+ <div className="wprev">
167
+ <div className="wphead">
168
+ <b className="wptitle">{p.title}</b>
169
+ <span className="wspath" title={p.file}>{p.id}</span>
170
+ <span className="seg wpplace" aria-label="where the preview sits">
171
+ {PLACES.map((pl) => (
172
+ <button key={pl} className={w.place === pl ? 'on' : ''} aria-label={`the preview on the ${pl}`} title={`the preview on the ${pl}`} onClick={() => w.setPlace(pl)}>
173
+ <Icon d={ICON[PLACE_ICON[pl]]} />
174
+ </button>
175
+ ))}
176
+ </span>
177
+ {editor == null ? null : (
178
+ <a className="wpbtn" href={editor} title={`open ${p.file} in VS Code`}><Icon d={ICON.editor} /><span>VS Code</span></a>
179
+ )}
180
+ <button className="wpbtn" title={`copy ${p.file}`} onClick={() => void w.copyPath()}><Icon d={ICON.copy} /><span>{w.copied ? 'copied' : 'copy path'}</span></button>
181
+ <button className="ghost wpclose" aria-label="close the preview" title="close the preview" onClick={() => w.close()}>×</button>
182
+ </div>
183
+ <div className="wpbody md readme-body" onClick={click} dangerouslySetInnerHTML={{ __html: renderWikiPage(p.md, p.links) }} />
55
184
  </div>
56
185
  )
57
186
  })
@@ -146,15 +146,15 @@ export async function resolveHubDeps(root: string, boot: RepoConfig, io: ServeIo
146
146
  // agent working here obeys that wiki, so a reader can open it.
147
147
  const graphs: GraphMount[] = []
148
148
  if (c.serve?.wikiGraph != null)
149
- graphs.push({ id: 'wiki', label: WIKI_LABEL, title: c.serve.wikiGraph.title ?? `${c.name} wiki`, load: c.serve.wikiGraph.load, lint: c.serve.wikiGraph.lint != null ? { checks: c.serve.wikiGraph.lint } : { root } })
149
+ graphs.push({ id: 'wiki', label: WIKI_LABEL, title: c.serve.wikiGraph.title ?? `${c.name} wiki`, load: c.serve.wikiGraph.load, dir: c.serve.wikiGraph.dir ?? root, lint: c.serve.wikiGraph.lint != null ? { checks: c.serve.wikiGraph.lint } : { root } })
150
150
  else if (existsSync(join(root, 'wiki')))
151
- graphs.push({ id: 'wiki', label: WIKI_LABEL, title: `${c.name} wiki`, load: () => scanMdGraph({ root }), lint: { root } })
151
+ graphs.push({ id: 'wiki', label: WIKI_LABEL, title: `${c.name} wiki`, load: () => scanMdGraph({ root }), dir: root, lint: { root } })
152
152
  const baseWiki = baseWikiRoot(root)
153
153
  if (baseWiki != null)
154
- graphs.push({ id: 'base', label: wikiLabel(baseWiki), title: wikiLabel(baseWiki), load: () => scanMdGraph({ root: baseWiki }), lint: null })
154
+ graphs.push({ id: 'base', label: wikiLabel(baseWiki), title: wikiLabel(baseWiki), load: () => scanMdGraph({ root: baseWiki }), dir: baseWiki, lint: null })
155
155
  const agentRoot = join(root, 'agent')
156
156
  if (existsSync(agentRoot))
157
- graphs.push({ id: 'agent', label: 'agent/', title: `${c.name} agent/`, load: () => scanMdGraph({ root: agentRoot, index: c.serve?.agentIndex }), lint: { root: agentRoot, index: c.serve?.agentIndex } })
157
+ graphs.push({ id: 'agent', label: 'agent/', title: `${c.name} agent/`, load: () => scanMdGraph({ root: agentRoot, index: c.serve?.agentIndex }), dir: agentRoot, lint: { root: agentRoot, index: c.serve?.agentIndex } })
158
158
  // ONE page for every repo: the studio ALWAYS
159
159
  // mounts — kit-less repos get an empty media section, never a second
160
160
  // page. A broken kit degrades to the same empty kit, loudly, and a repo
@@ -30,6 +30,8 @@ export type { Gate }
30
30
  export const POLL_MS = 5_000
31
31
  /** the app icon preview: sharp on a 2x screen at the admin page's 128px */
32
32
  const PREVIEW_PX = 256
33
+ /** the rail's app icon, cropped to the plate: 40px at 2x, the largest rail size */
34
+ const RAIL_PX = 96
33
35
  /** how long a release answer holds for a checkout whose `.git` is not a
34
36
  * folder, since no stat can say when it moved */
35
37
  export const RELEASE_TTL_MS = 60_000
@@ -74,6 +76,8 @@ export type ProjectRow = {
74
76
  live: string | null
75
77
  /** the colours its repo.config.ts declares (SK024), null when it declares none */
76
78
  identity: Identity | null
79
+ /** its app icon cropped to the plate (core/app-icon.ts), what the folded rail draws; null with no mark */
80
+ appIcon: string | null
77
81
  /** epoch ms of the newest of: the last commit, the last shipkit run, the
78
82
  * newest Claude Code transcript. null when none of them exists */
79
83
  activity: number | null
@@ -298,6 +302,15 @@ export function projectGutter(d: GutterDeps) {
298
302
  }
299
303
 
300
304
  const iconUrl = (r: WiredRepo): string | null => (r.icon == null ? null : `/api/projects/icon/${encodeURIComponent(r.name)}?v=${iconStamp(join(r.path, r.icon))}`)
305
+ // the url names the mark's stamp and both colours, so a new mark or a colour change is a new url, cached a day
306
+ const railIconUrl = (r: WiredRepo, id: Identity): string | null => {
307
+ const mark = appIconOf(r.path)
308
+ if (mark == null) return null
309
+ const q = new URLSearchParams({ plate: '1', light: id.colorLight, dark: id.colorDark, v: String(iconStamp(mark)) })
310
+ return `/api/projects/app-icon/${encodeURIComponent(r.name)}?${q}`
311
+ }
312
+ /** drawn plates, by url: a page load asks for every repo's at once */
313
+ const plates = new Map<string, Promise<Buffer>>()
301
314
 
302
315
  async function measure(): Promise<ProjectsAnswer> {
303
316
  const t0 = performance.now()
@@ -334,6 +347,7 @@ export function projectGutter(d: GutterDeps) {
334
347
  release: facts[i]!.release,
335
348
  live: liveUrlOf(config),
336
349
  identity: identityOfText(config),
350
+ appIcon: railIconUrl(r, identityOfRoot(r.path)),
337
351
  activity: lastActivity(facts[i]!.commitAt, lastRunAt(r.path), transcripts.get(r.path)),
338
352
  commit: facts[i]!.commitAt,
339
353
  }
@@ -386,12 +400,22 @@ export function projectGutter(d: GutterDeps) {
386
400
  const r = findAny(decodeURIComponent(path.slice('/api/projects/app-icon/'.length)))
387
401
  if (r == null) return new Response('not a house repo', { status: 404 })
388
402
  const icon = appIconOf(r.path)
389
- if (icon == null) return new Response('no png mark: the app wears the default icon', { status: 404 })
403
+ if (icon == null) return new Response('no mark: the app wears the default icon', { status: 404 })
390
404
  // the colours a picker is on right now, before they are saved; else the config's
391
405
  const q = new URL(req.url).searchParams
392
406
  const asked = { colorLight: q.get('light') ?? '', colorDark: q.get('dark') ?? '' }
393
407
  const id = identityProblem(asked) == null ? asked : identityOfRoot(r.path)
394
408
  try {
409
+ if (q.get('plate') === '1') {
410
+ const key = `${r.path}?${q}`
411
+ let png = plates.get(key)
412
+ if (png == null) {
413
+ png = readFile(icon).then(async (b) => (await appIconPngs(b, id, [RAIL_PX], 'plate'))[0]!)
414
+ plates.set(key, png)
415
+ png.catch(() => plates.delete(key))
416
+ }
417
+ return new Response(new Uint8Array(await png), { headers: { 'content-type': 'image/png', 'cache-control': 'max-age=86400' } })
418
+ }
395
419
  const [png] = await appIconPngs(await readFile(icon), id, [PREVIEW_PX])
396
420
  return new Response(new Uint8Array(png!), { headers: { 'content-type': 'image/png', 'cache-control': 'max-age=60' } })
397
421
  } catch (e) {
@@ -48,7 +48,7 @@ export function houseIndex(repo: string, home: string): string | null {
48
48
 
49
49
  /** a repo's own icon, first hit wins. `.shipkit/identity/icon.png` is the rail
50
50
  * icon a repo with no public surface carries, the identity kit writes public/ */
51
- export const ICON_FILES = ['.shipkit/identity/icon.png', 'public/favicon.png', 'public/favicon.svg', 'public/favicon.ico', 'favicon.ico', 'public/logo.png']
51
+ export const ICON_FILES = ['.shipkit/identity/icon.png', '.shipkit/identity/icon.svg', 'public/favicon.png', 'public/favicon.svg', 'public/favicon.ico', 'favicon.ico', 'public/logo.png']
52
52
 
53
53
  export const ICON_TYPES: Record<string, string> = { png: 'image/png', svg: 'image/svg+xml', ico: 'image/x-icon' }
54
54
 
@@ -5,7 +5,11 @@
5
5
  import { basename } from 'path'
6
6
  import { reclaimPreviousImage } from '../core/instance.ts'
7
7
  import { isLinkedWorktree } from '../core/git-root.ts'
8
+ import { heldNow, heldText, OWN_LIB, setHeld } from '../core/last-good.ts'
8
9
  import { diskLibVersion, LIB_VERSION } from '../core/lib-version.ts'
10
+ import { bootEntries, probeLoad, TreeProbe } from '../core/load-probe.ts'
11
+ import { LibWatch } from '../home/lib-watch.ts'
12
+ import type { LoadVerdict } from './stale-restart.ts'
9
13
  import { reexecBlocker, reexecSelf } from '../core/reexec.ts'
10
14
  import { attachToHome } from '../home/attach.ts'
11
15
  import { daemonUp, WORKER_ENV } from '../home/home-paths.ts'
@@ -21,6 +25,14 @@ export type { ServeIo }
21
25
  /** how often a hub of its own looks at its plan */
22
26
  export const PLAN_TICK_MS = 2_000
23
27
 
28
+ /** does the lib this hub would restart onto load: probed again after every
29
+ * write to its tree, not only when the version moves */
30
+ export function serveProbe(lib: string): { loads: () => LoadVerdict; stop: () => void } {
31
+ const w = new LibWatch(lib)
32
+ const p = new TreeProbe(() => probeLoad(lib, bootEntries(lib, 'serve')), () => `${w.seq}|${diskLibVersion()}`)
33
+ return { loads: () => p.loads(), stop: () => w.close() }
34
+ }
35
+
24
36
  /** what a `shipkit serve` does with its hub. On a machine with a home, no
25
37
  * hub runs outside it: a home that answers takes it, and an installed one
26
38
  * that does not is started first. `serve` only inside the home, a linked
@@ -104,6 +116,7 @@ export async function runServe(root: string, io: ServeIo, o?: { port?: number })
104
116
  // a file table the watch restarts leaked: it re-executes itself in
105
117
  // place (same pid, argv, port) once no run is in flight. Only a
106
118
  // restart that throws is said, in the hub's own status
119
+ const probe = serveProbe(OWN_LIB)
107
120
  const stale = new StaleRestart({
108
121
  boot: LIB_VERSION,
109
122
  disk: diskLibVersion,
@@ -114,6 +127,7 @@ export async function runServe(root: string, io: ServeIo, o?: { port?: number })
114
127
  if (blocked != null) throw new Error(blocked)
115
128
  io.print(`ok: ${why}: restarting in place`)
116
129
  stale.stop()
130
+ probe.stop()
117
131
  clearInterval(planClock)
118
132
  rt.stop()
119
133
  hub.stop()
@@ -126,6 +140,16 @@ export async function runServe(root: string, io: ServeIo, o?: { port?: number })
126
140
  }
127
141
  },
128
142
  say: (text) => deps.hubProblems?.set('stale', text == null ? null : { text, severity: 'red' }),
143
+ // a version move restarts only onto code that loads; the held line is the hub's (serve-hub.ts)
144
+ loads: () => probe.loads(),
145
+ hold: (v) => {
146
+ if (v == null) return setHeld(null)
147
+ const was = heldNow()
148
+ if (was?.file === v.file && was.error === v.error) return
149
+ const h = { since: was?.since ?? Date.now(), file: v.file, error: v.error, from: OWN_LIB, copyAt: null }
150
+ io.printErr(`WARN ${heldText(h)}`)
151
+ setHeld(h)
152
+ },
129
153
  })
130
154
  stale.start()
131
155
  // a watch restart that leaked sheds it before the first run spawns anything
@@ -140,6 +164,7 @@ export async function runServe(root: string, io: ServeIo, o?: { port?: number })
140
164
  for (const sig of ['SIGINT', 'SIGTERM'] as const)
141
165
  process.on(sig, () => {
142
166
  stale.stop()
167
+ probe.stop()
143
168
  clearInterval(planClock)
144
169
  plan.stop()
145
170
  rt.stop()