dsh-notch 0.3.1 → 0.3.6

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 (102) hide show
  1. package/README.en.md +58 -47
  2. package/README.md +60 -55
  3. package/cordis.patch.yml +3 -0
  4. package/desktop/README.md +29 -0
  5. package/desktop/notch-lifecycle.mjs +120 -0
  6. package/desktop/restart-probe.mjs +61 -0
  7. package/docs/desktop-017rc2.md +23 -0
  8. package/docs/elastic-edge-dock.md +127 -0
  9. package/docs/motion-audit.md +1 -1
  10. package/docs/releases/v0.3.2.md +38 -0
  11. package/docs/releases/v0.3.3.md +9 -0
  12. package/docs/subagent-compatibility.md +51 -0
  13. package/docs/verification/elastic-algorithm-timing.json +337 -0
  14. package/docs/verification/elastic-attention.json +44 -0
  15. package/docs/verification/elastic-frame-timing.json +240 -0
  16. package/docs/verification/elastic-tension.json +59 -0
  17. package/docs/verification/elastic-tip-taper.json +181 -0
  18. package/dshx.yml +2 -0
  19. package/lib/client.js +112 -0
  20. package/lib/client.js.map +1 -0
  21. package/lib/index.mjs +990 -0
  22. package/lib/index.mjs.map +1 -0
  23. package/package.json +66 -10
  24. package/src/board.ts +178 -35
  25. package/src/client/index.ts +104 -0
  26. package/src/dsh-notch.ts +19 -2
  27. package/src/http.ts +17 -2
  28. package/src/session-state.ts +15 -1
  29. package/src/store.ts +3 -3
  30. package/src/types.ts +3 -0
  31. package/BASELINE.md +0 -13
  32. package/DESIGN.md +0 -34
  33. package/cordis.yml +0 -5
  34. package/macos/Package.swift +0 -17
  35. package/macos/STATUS-MOTION.md +0 -49
  36. package/macos/Sources/Client.swift +0 -139
  37. package/macos/Sources/IdleRobot.swift +0 -423
  38. package/macos/Sources/NotchMarkdown.swift +0 -266
  39. package/macos/Sources/Panel.swift +0 -153
  40. package/macos/Sources/Resources/Idle/balance.json +0 -1
  41. package/macos/Sources/Resources/Idle/blink.json +0 -1
  42. package/macos/Sources/Resources/Idle/cube-in.json +0 -1
  43. package/macos/Sources/Resources/Idle/dance.json +0 -1
  44. package/macos/Sources/Resources/Idle/hop.json +0 -1
  45. package/macos/Sources/Resources/Idle/nod.json +0 -1
  46. package/macos/Sources/Resources/Idle/satellite-in.json +0 -1
  47. package/macos/Sources/Resources/Idle/satellite-out.json +0 -1
  48. package/macos/Sources/Resources/Idle/scan.json +0 -1
  49. package/macos/Sources/Resources/Idle/sleep.json +0 -1
  50. package/macos/Sources/Resources/Idle/sneeze.json +0 -1
  51. package/macos/Sources/Resources/Idle/stretch.json +0 -1
  52. package/macos/Sources/Resources/Idle/tilt.json +0 -1
  53. package/macos/Sources/ReviewStudio.swift +0 -337
  54. package/macos/Sources/RootView.swift +0 -1093
  55. package/macos/Sources/StatusOrbit.swift +0 -642
  56. package/macos/Sources/main.swift +0 -153
  57. package/macos/Tests/ExpandedHeightProbe.swift +0 -108
  58. package/macos/Tests/GeometryProbe.swift +0 -98
  59. package/macos/Tests/IdleProbe.swift +0 -294
  60. package/macos/Tests/MotionProbe.swift +0 -106
  61. package/macos/Tests/OutcomeProbe.swift +0 -133
  62. package/macos/Tests/ScrollbarProbe.swift +0 -91
  63. package/macos/Tests/expanded-height.sh +0 -13
  64. package/macos/Tests/geometry.sh +0 -14
  65. package/macos/Tests/idle.sh +0 -14
  66. package/macos/Tests/motion.sh +0 -14
  67. package/macos/Tests/outcome.sh +0 -14
  68. package/macos/Tests/scrollbar.sh +0 -13
  69. package/tests/answer-lifecycle.test.mjs +0 -23
  70. package/tests/board.test.mjs +0 -7
  71. package/tests/browse-sync.test.mjs +0 -20
  72. package/tests/mirror.test.mjs +0 -4
  73. package/tests/peer-range.test.mjs +0 -21
  74. package/tests/session-state.test.mjs +0 -54
  75. package/tests/unread-hold.test.mjs +0 -90
  76. package/tools/desktop-shell/README.md +0 -56
  77. package/tools/desktop-shell/child-log.mjs +0 -6
  78. package/tools/desktop-shell/child-log.test.mjs +0 -14
  79. package/tools/desktop-shell/package-recovery.py +0 -78
  80. package/tools/desktop-shell/recovery-probe.cjs +0 -50
  81. package/tools/desktop-shell/renderer-recovery.mjs +0 -95
  82. package/tools/desktop-shell/renderer-recovery.test.mjs +0 -24
  83. package/tools/diagnostics/completion-watch.py +0 -101
  84. package/tools/diagnostics/completion-watch.test.py +0 -17
  85. package/tools/diagnostics/renderer-diagnostics.mjs +0 -56
  86. package/tools/diagnostics/renderer-diagnostics.test.mjs +0 -32
  87. package/tools/idle/LICENSE.open-bot-motion +0 -21
  88. package/tools/idle/export.mjs +0 -40
  89. package/tools/idle/index.html +0 -4
  90. package/tools/idle/motions.js +0 -83
  91. package/tools/idle/original.js +0 -1673
  92. package/tools/idle/renderer.js +0 -1673
  93. package/tools/recording/Client.swift +0 -76
  94. package/tools/recording/DemoCatalog.swift +0 -174
  95. package/tools/recording/README.md +0 -23
  96. package/tools/recording/build.sh +0 -41
  97. package/tools/recording/main.swift +0 -100
  98. package/tools/review/ReviewStudio.swift +0 -236
  99. package/tools/review/audit.sh +0 -19
  100. package/tools/review/build.sh +0 -7
  101. package/tools/review/main.swift +0 -158
  102. package/tools/review/render-audit.swift +0 -32
package/lib/index.mjs ADDED
@@ -0,0 +1,990 @@
1
+ import Schema from "@deepseek-ai/schemastery";
2
+ import { createWriteStream, existsSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import { dirname, join, resolve } from "node:path";
4
+ import { execFileSync, spawn } from "node:child_process";
5
+ import { randomBytes, randomUUID } from "node:crypto";
6
+ import { resolveDshHome } from "@deepseek-ai/dsh-home-paths";
7
+ //#region desktop/notch-lifecycle.mjs
8
+ /** PID alone is not ownership: require both executable and process start time. */
9
+ function processIdentity(pid) {
10
+ if (!Number.isSafeInteger(pid) || pid <= 1) return { state: "dead" };
11
+ try {
12
+ const text = execFileSync("/bin/ps", [
13
+ "-p",
14
+ String(pid),
15
+ "-o",
16
+ "lstart=",
17
+ "-o",
18
+ "comm="
19
+ ], {
20
+ encoding: "utf8",
21
+ env: {
22
+ ...process.env,
23
+ LC_ALL: "C"
24
+ },
25
+ timeout: 1e3
26
+ }).trim();
27
+ const match = /^(.{24})\s+(.+)$/.exec(text);
28
+ if (match) return {
29
+ state: "alive",
30
+ stamp: match[1],
31
+ command: match[2]
32
+ };
33
+ } catch {}
34
+ try {
35
+ process.kill(pid, 0);
36
+ } catch (error) {
37
+ if (error.code === "ESRCH") return { state: "dead" };
38
+ }
39
+ return { state: "unknown" };
40
+ }
41
+ function processLiveness(pid) {
42
+ if (!Number.isSafeInteger(pid) || pid <= 1) return "dead";
43
+ try {
44
+ process.kill(pid, 0);
45
+ return "alive";
46
+ } catch (error) {
47
+ return error.code === "ESRCH" ? "dead" : "unknown";
48
+ }
49
+ }
50
+ /** The desktop owns an adopted helper too. If it exits during adoption, retry. */
51
+ function superviseNotch({ bin, pidPath, log, interval = 250, env = process.env, probe = processIdentity, alive = processLiveness, launch = spawn, signal = process.kill.bind(process) }) {
52
+ let current, pending, timer, stopped = false;
53
+ const starts = [];
54
+ const note = (text) => log.write(`[notch] ${text}\n`);
55
+ const storedPID = () => {
56
+ try {
57
+ return Number(readFileSync(pidPath, "utf8").trim());
58
+ } catch {
59
+ return 0;
60
+ }
61
+ };
62
+ const owned = (pid) => {
63
+ const identity = probe(pid);
64
+ return identity.state === "alive" && resolve(identity.command) === resolve(bin) ? {
65
+ pid,
66
+ ...identity,
67
+ checkedAt: Date.now()
68
+ } : void 0;
69
+ };
70
+ const schedule = () => {
71
+ clearTimeout(timer);
72
+ if (!stopped) {
73
+ timer = setTimeout(reconcile, interval);
74
+ timer.unref?.();
75
+ }
76
+ };
77
+ function reconcile() {
78
+ if (stopped) return;
79
+ if (pending) {
80
+ schedule();
81
+ return;
82
+ }
83
+ try {
84
+ if (Date.now() - statSync(`${pidPath}.updating`).mtimeMs < 6e4) {
85
+ schedule();
86
+ return;
87
+ }
88
+ } catch {}
89
+ if (current) {
90
+ const state = alive(current.pid);
91
+ if (state === "unknown" || state === "alive" && Date.now() - current.checkedAt < 5e3) {
92
+ schedule();
93
+ return;
94
+ }
95
+ const live = probe(current.pid);
96
+ if (live.state === "unknown" || live.state === "alive" && live.stamp === current.stamp && live.command === current.command) {
97
+ current.checkedAt = Date.now();
98
+ schedule();
99
+ return;
100
+ }
101
+ current = void 0;
102
+ }
103
+ const pid = storedPID();
104
+ if (probe(pid).state === "unknown") {
105
+ schedule();
106
+ return;
107
+ }
108
+ current = owned(pid);
109
+ if (current) {
110
+ note(`adopt ${current.pid}`);
111
+ schedule();
112
+ return;
113
+ }
114
+ while (starts.length && Date.now() - starts[0] > 1e4) starts.shift();
115
+ if (starts.length >= 3) {
116
+ note("helper repeatedly exited; retry paused");
117
+ stopped = true;
118
+ return;
119
+ }
120
+ starts.push(Date.now());
121
+ const child = launch(bin, [], {
122
+ stdio: [
123
+ "ignore",
124
+ "pipe",
125
+ "pipe"
126
+ ],
127
+ detached: false,
128
+ env
129
+ });
130
+ pending = child;
131
+ child.stdout?.pipe(log, { end: false });
132
+ child.stderr?.pipe(log, { end: false });
133
+ child.once("spawn", () => {
134
+ if (pending !== child) return;
135
+ pending = void 0;
136
+ if (stopped) {
137
+ child.kill("SIGTERM");
138
+ return;
139
+ }
140
+ current = owned(child.pid);
141
+ if (!current) {
142
+ child.kill("SIGTERM");
143
+ schedule();
144
+ return;
145
+ }
146
+ mkdirSync(dirname(pidPath), { recursive: true });
147
+ const stage = `${pidPath}.${process.pid}.tmp`;
148
+ writeFileSync(stage, `${child.pid}\n`);
149
+ renameSync(stage, pidPath);
150
+ note(`spawn ${child.pid}`);
151
+ schedule();
152
+ });
153
+ child.once("error", (error) => {
154
+ if (pending === child) pending = void 0;
155
+ note(`spawn error: ${error.message}`);
156
+ schedule();
157
+ });
158
+ child.once("exit", () => {
159
+ if (pending === child) pending = void 0;
160
+ if (current?.pid === child.pid) current = void 0;
161
+ schedule();
162
+ });
163
+ schedule();
164
+ }
165
+ const stop = () => {
166
+ stopped = true;
167
+ clearTimeout(timer);
168
+ pending?.kill("SIGTERM");
169
+ const candidates = [current, owned(storedPID())].filter(Boolean);
170
+ const seen = /* @__PURE__ */ new Set();
171
+ for (const item of candidates) {
172
+ if (seen.has(item.pid)) continue;
173
+ seen.add(item.pid);
174
+ const live = owned(item.pid);
175
+ if (!live || live.stamp !== item.stamp) continue;
176
+ try {
177
+ signal(item.pid, "SIGTERM");
178
+ note(`stop ${item.pid}`);
179
+ } catch (error) {
180
+ if (error.code !== "ESRCH") note(`stop failed: ${error.message}`);
181
+ }
182
+ if (storedPID() === item.pid) try {
183
+ unlinkSync(pidPath);
184
+ } catch {}
185
+ }
186
+ current = void 0;
187
+ };
188
+ reconcile();
189
+ return {
190
+ get pid() {
191
+ return current?.pid ?? pending?.pid;
192
+ },
193
+ stop,
194
+ kill: stop
195
+ };
196
+ }
197
+ //#endregion
198
+ //#region src/session-state.ts
199
+ const FAILED = /* @__PURE__ */ new Set([
200
+ "error",
201
+ "blocked",
202
+ "max-tokens"
203
+ ]);
204
+ function foldSession(session) {
205
+ let busy = false;
206
+ let lastTurn;
207
+ for (const event of session.snapshotEvents()) {
208
+ if (event.type === "turn/start") {
209
+ busy = true;
210
+ lastTurn = void 0;
211
+ continue;
212
+ }
213
+ if (event.type === "turn/end") {
214
+ busy = false;
215
+ const kind = event.data.reason.kind;
216
+ lastTurn = {
217
+ at: event.time,
218
+ kind,
219
+ failed: FAILED.has(kind)
220
+ };
221
+ }
222
+ }
223
+ return {
224
+ busy,
225
+ lastTurn
226
+ };
227
+ }
228
+ function isChildSession(session) {
229
+ return session.header.origin === "subagent";
230
+ }
231
+ /** Follow only delegation edges; user forks remain separate conversations. */
232
+ function conversationOwner(session, sessions) {
233
+ let current = session;
234
+ const visited = /* @__PURE__ */ new Set();
235
+ while (current && isChildSession(current)) {
236
+ if (visited.has(current.id)) return void 0;
237
+ visited.add(current.id);
238
+ current = current.header.parentSession === void 0 ? void 0 : sessions.get(current.header.parentSession);
239
+ }
240
+ return current;
241
+ }
242
+ //#endregion
243
+ //#region src/store.ts
244
+ const DIR = join(resolveDshHome(), "dsh-notch");
245
+ const RUNTIME = join(DIR, "runtime.json");
246
+ const SEEN = join(DIR, "seen.json");
247
+ function ensureDir() {
248
+ mkdirSync(DIR, {
249
+ recursive: true,
250
+ mode: 448
251
+ });
252
+ }
253
+ function readJson(path) {
254
+ try {
255
+ return JSON.parse(readFileSync(path, "utf8"));
256
+ } catch {
257
+ return;
258
+ }
259
+ }
260
+ function loadOrCreateToken() {
261
+ const existing = readJson(RUNTIME);
262
+ if (existing?.token && existing.token.length >= 16) return existing.token;
263
+ return randomBytes(24).toString("hex");
264
+ }
265
+ function writeRuntime(file) {
266
+ ensureDir();
267
+ writeFileSync(RUNTIME, `${JSON.stringify(file, null, 2)}\n`, {
268
+ encoding: "utf8",
269
+ mode: 384
270
+ });
271
+ }
272
+ function loadSeen() {
273
+ const value = readJson(SEEN);
274
+ if (!value || typeof value !== "object") return {};
275
+ return value;
276
+ }
277
+ function saveSeen(map) {
278
+ ensureDir();
279
+ writeFileSync(SEEN, `${JSON.stringify(map)}\n`, {
280
+ encoding: "utf8",
281
+ mode: 384
282
+ });
283
+ }
284
+ //#endregion
285
+ //#region src/board.ts
286
+ var Board = class {
287
+ ctx;
288
+ sidebar;
289
+ instance = randomUUID();
290
+ traces = [];
291
+ lastMirrorTrace = "";
292
+ lastRowTrace = "";
293
+ /** Bounded, content-free diagnostics for state-source races. */
294
+ diagnostics() {
295
+ return {
296
+ stateRevision: 4,
297
+ instance: this.instance,
298
+ sidebar: this.sidebar && {
299
+ clientId: this.sidebar.clientId,
300
+ at: this.sidebar.at,
301
+ focused: this.sidebar.focused
302
+ },
303
+ traces: this.traces
304
+ };
305
+ }
306
+ trace(event, value) {
307
+ this.traces.push({
308
+ at: Date.now(),
309
+ event,
310
+ ...value
311
+ });
312
+ if (this.traces.length > 160) this.traces.shift();
313
+ }
314
+ syncSidebar(input) {
315
+ if (!input || typeof input !== "object") return false;
316
+ const data = input;
317
+ if (typeof data.clientId !== "string" || !Array.isArray(data.rows) || data.rows.length > 1e3) return false;
318
+ const rows = [];
319
+ for (const row of data.rows) {
320
+ if (!row || typeof row.id !== "string" || row.id.length === 0 || typeof row.title !== "string" || typeof row.completed !== "boolean" || typeof row.running !== "boolean") return false;
321
+ rows.push({
322
+ id: row.id,
323
+ title: row.title.slice(0, 512),
324
+ completed: row.completed,
325
+ running: row.running,
326
+ ...typeof row.updatedAt === "number" && Number.isFinite(row.updatedAt) ? { updatedAt: row.updatedAt } : {}
327
+ });
328
+ }
329
+ const sessions = new Map(this.ctx.sessions.list().map((session) => [session.id, session]));
330
+ for (const row of rows) {
331
+ if (!row.completed || row.running) continue;
332
+ const session = sessions.get(row.id);
333
+ if (session && (isChildSession(session) || this.isBusy(session))) continue;
334
+ const turn = session ? foldSession(session).lastTurn : void 0;
335
+ const seen = this.seen[row.id];
336
+ const completedAt = turn?.at ?? row.updatedAt;
337
+ if (seen !== void 0 && (completedAt === void 0 || seen >= completedAt)) continue;
338
+ this.browserCompletions.set(row.id, row);
339
+ }
340
+ const viewed = data.viewed;
341
+ if (data.projectionVersion === 3 && data.focused === true && viewed && typeof viewed.id === "string" && typeof viewed.at === "number" && Number.isFinite(viewed.at) && rows.some((row) => row.id === viewed.id && !row.running)) {
342
+ const session = sessions.get(viewed.id);
343
+ if (!session || !isChildSession(session) && !this.conversationBusy(session, sessions)) this.readThrough(viewed.id, Math.min(viewed.at, Date.now()));
344
+ }
345
+ if (data.projectionVersion !== 3 && data.focused === true && this.sidebar?.clientId === data.clientId) for (const previous of this.sidebar.rows) {
346
+ if (!previous.completed || rows.some((row) => row.id === previous.id && row.completed)) continue;
347
+ const session = sessions.get(previous.id);
348
+ const at = session ? foldSession(session).lastTurn?.at : Date.now();
349
+ if (at !== void 0 && (!session || !this.conversationBusy(session, sessions))) this.readThrough(previous.id, at);
350
+ }
351
+ if (this.sidebar && this.sidebar.clientId !== data.clientId && data.focused !== true && Date.now() - this.sidebar.at < 5e3) {
352
+ this.bump();
353
+ return true;
354
+ }
355
+ this.sidebar = {
356
+ clientId: data.clientId,
357
+ at: Date.now(),
358
+ focused: data.focused === true,
359
+ rows,
360
+ ...data.projectionVersion === 2 || data.projectionVersion === 3 ? { projectionVersion: data.projectionVersion } : {}
361
+ };
362
+ const mirrorTrace = {
363
+ clientId: data.clientId,
364
+ focused: data.focused === true,
365
+ rows: rows.map(({ id, running, completed }) => ({
366
+ id,
367
+ running,
368
+ completed
369
+ }))
370
+ };
371
+ const signature = JSON.stringify(mirrorTrace);
372
+ if (signature !== this.lastMirrorTrace) {
373
+ this.lastMirrorTrace = signature;
374
+ this.trace("sidebar", mirrorTrace);
375
+ }
376
+ this.bump();
377
+ return true;
378
+ }
379
+ sidebarRows() {
380
+ return this.sidebar && Date.now() - this.sidebar.at < 5e3 ? this.sidebar.rows : void 0;
381
+ }
382
+ pending = /* @__PURE__ */ new Map();
383
+ seen = loadSeen();
384
+ running = /* @__PURE__ */ new Set();
385
+ pendingUnread = /* @__PURE__ */ new Set();
386
+ browserCompletions = /* @__PURE__ */ new Map();
387
+ listeners = /* @__PURE__ */ new Set();
388
+ focus = null;
389
+ constructor(ctx) {
390
+ this.ctx = ctx;
391
+ }
392
+ onChange(fn) {
393
+ this.listeners.add(fn);
394
+ return () => {
395
+ this.listeners.delete(fn);
396
+ };
397
+ }
398
+ notify() {
399
+ this.bump();
400
+ }
401
+ bump() {
402
+ for (const fn of this.listeners) try {
403
+ fn();
404
+ } catch (error) {
405
+ this.ctx.logger.warn("dsh-notch: subscriber failed: %s", String(error));
406
+ }
407
+ }
408
+ snapshot(origin) {
409
+ const rows = [];
410
+ const sessions = this.ctx.sessions.list();
411
+ const byId = new Map(sessions.map((session) => [session.id, session]));
412
+ const groups = /* @__PURE__ */ new Map();
413
+ for (const session of sessions) {
414
+ const owner = conversationOwner(session, byId);
415
+ if (!owner) {
416
+ const ids = /* @__PURE__ */ new Set([session.id]);
417
+ const approval = this.heldApproval(ids);
418
+ const ask = this.heldAsk(ids);
419
+ if (approval || ask) rows.push({
420
+ id: session.id,
421
+ title: this.titleOf(session),
422
+ child: true,
423
+ busy: false,
424
+ unread: false,
425
+ ...approval ? { approval } : {},
426
+ ...ask ? { ask } : {}
427
+ });
428
+ continue;
429
+ }
430
+ const members = groups.get(owner) ?? [];
431
+ members.push(session);
432
+ groups.set(owner, members);
433
+ }
434
+ for (const [owner, members] of groups) {
435
+ const row = this.rowFor(owner, members);
436
+ if (row) rows.push(row);
437
+ }
438
+ const freshSidebar = this.sidebarRows();
439
+ const coldRows = new Map(this.browserCompletions);
440
+ for (const source of freshSidebar ?? []) if (source.running) coldRows.set(source.id, source);
441
+ for (const source of coldRows.values()) {
442
+ if (byId.has(source.id) || !source.completed && !source.running) continue;
443
+ if (source.running && !freshSidebar) continue;
444
+ const seen = this.seen[source.id];
445
+ if (source.completed && seen !== void 0 && (source.updatedAt === void 0 || seen >= source.updatedAt)) continue;
446
+ rows.push({
447
+ id: source.id,
448
+ title: source.title,
449
+ child: false,
450
+ busy: source.running,
451
+ unread: source.completed
452
+ });
453
+ }
454
+ rows.sort((a, b) => Number(Boolean(b.approval || b.ask)) - Number(Boolean(a.approval || a.ask)) || Number(b.busy) - Number(a.busy) || Number(b.unread) - Number(a.unread) || (b.lastTurn?.at ?? 0) - (a.lastTurn?.at ?? 0));
455
+ this.running = new Set(sessions.filter((session) => this.isBusy(session)).map((session) => session.id));
456
+ const stateRows = rows.map((row) => ({
457
+ id: row.id,
458
+ busy: row.busy,
459
+ unread: row.unread,
460
+ turnAt: row.lastTurn?.at,
461
+ action: Boolean(row.ask || row.approval)
462
+ }));
463
+ const rowSignature = JSON.stringify(stateRows);
464
+ if (rowSignature !== this.lastRowTrace) {
465
+ this.lastRowTrace = rowSignature;
466
+ this.trace("snapshot", {
467
+ rows: stateRows,
468
+ clientId: this.sidebar?.clientId,
469
+ mirrorAt: this.sidebar?.at,
470
+ running: [...this.running],
471
+ pendingUnread: [...this.pendingUnread]
472
+ });
473
+ }
474
+ return {
475
+ ok: true,
476
+ stateRevision: 4,
477
+ generatedAt: Date.now(),
478
+ origin,
479
+ rows,
480
+ sidebarSyncedAt: freshSidebar ? this.sidebar?.at : void 0,
481
+ sidebarProjectionVersion: freshSidebar ? this.sidebar?.projectionVersion : void 0
482
+ };
483
+ }
484
+ markSeen(sessionId) {
485
+ this.pendingUnread.delete(sessionId);
486
+ this.browserCompletions.delete(sessionId);
487
+ this.seen[sessionId] = Date.now();
488
+ saveSeen(this.seen);
489
+ this.bump();
490
+ }
491
+ markAllSeen() {
492
+ this.pendingUnread.clear();
493
+ const now = Date.now();
494
+ for (const id of this.browserCompletions.keys()) this.seen[id] = now;
495
+ this.browserCompletions.clear();
496
+ for (const session of this.ctx.sessions.list()) this.seen[session.id] = now;
497
+ saveSeen(this.seen);
498
+ this.bump();
499
+ }
500
+ readThrough(sessionId, at) {
501
+ const seen = this.seen[sessionId];
502
+ if ((seen ?? -Infinity) >= at) return;
503
+ const session = this.ctx.sessions.list().find((item) => item.id === sessionId);
504
+ const completedAt = (session && foldSession(session).lastTurn)?.at ?? this.browserCompletions.get(sessionId)?.updatedAt;
505
+ if (seen !== void 0 && (completedAt === void 0 || seen >= completedAt)) return;
506
+ this.seen[sessionId] = at;
507
+ if (completedAt === void 0 || completedAt <= at) {
508
+ this.pendingUnread.delete(sessionId);
509
+ this.browserCompletions.delete(sessionId);
510
+ }
511
+ saveSeen(this.seen);
512
+ this.trace("read", {
513
+ sessionId,
514
+ through: at
515
+ });
516
+ }
517
+ noteTurnEnd(session, at = foldSession(session).lastTurn?.at) {
518
+ if (isChildSession(session)) return;
519
+ if (at !== void 0 && (this.seen[session.id] ?? -Infinity) < at) this.pendingUnread.add(session.id);
520
+ }
521
+ /**
522
+ * Record a "show this session in a DSH UI" wish from the native helper.
523
+ * Returns false for unknown sessions. The wish expires after 60s; every
524
+ * open DSH page consumes it idempotently, so no per-client cursor is kept.
525
+ */
526
+ requestFocus(sessionId) {
527
+ const known = this.ctx.sessions.list().some((session) => session.id === sessionId);
528
+ const mirrored = this.browserCompletions.has(sessionId) || this.sidebarRows()?.some((row) => row.id === sessionId);
529
+ if (!known && !mirrored) return false;
530
+ this.focus = {
531
+ sessionId,
532
+ at: Date.now()
533
+ };
534
+ this.bump();
535
+ return true;
536
+ }
537
+ peekFocus() {
538
+ if (this.focus === null) return null;
539
+ if (Date.now() - this.focus.at > 6e4) {
540
+ this.focus = null;
541
+ return null;
542
+ }
543
+ return this.focus;
544
+ }
545
+ decideApproval(id, outcome) {
546
+ const held = this.pending.get(id);
547
+ if (!held || held.kind !== "approval") return false;
548
+ this.pending.delete(id);
549
+ this.seen[held.sessionId] = Date.now();
550
+ saveSeen(this.seen);
551
+ held.resolve(outcome);
552
+ this.bump();
553
+ return true;
554
+ }
555
+ answerAsk(id, answers) {
556
+ const held = this.pending.get(id);
557
+ if (!held || held.kind !== "ask") return false;
558
+ this.pending.delete(id);
559
+ this.seen[held.sessionId] = Date.now();
560
+ saveSeen(this.seen);
561
+ held.resolve({ answers });
562
+ this.bump();
563
+ return true;
564
+ }
565
+ holdApproval(request, next) {
566
+ const sessionId = String(request.agent.id);
567
+ const id = randomUUID();
568
+ const fromNotch = new Promise((resolve) => {
569
+ const held = {
570
+ kind: "approval",
571
+ id,
572
+ sessionId,
573
+ toolName: request.toolName,
574
+ resolve
575
+ };
576
+ if (request.reason) held.reason = request.reason;
577
+ this.pending.set(id, held);
578
+ this.bump();
579
+ request.signal?.addEventListener("abort", () => {
580
+ if (!this.pending.delete(id)) return;
581
+ this.bump();
582
+ }, { once: true });
583
+ });
584
+ return Promise.race([fromNotch, next()]).finally(() => {
585
+ if (this.pending.delete(id)) this.bump();
586
+ });
587
+ }
588
+ holdAsk(request, next) {
589
+ const sessionId = request.agent ? String(request.agent.id) : "";
590
+ if (!sessionId) return next();
591
+ const originalSignal = request.signal;
592
+ const downstream = new AbortController();
593
+ request.signal = originalSignal ? AbortSignal.any([originalSignal, downstream.signal]) : downstream.signal;
594
+ const id = randomUUID();
595
+ const questions = request.questions.map((item) => ({
596
+ id: item.id,
597
+ question: item.question,
598
+ ...item.detail === void 0 ? {} : { detail: item.detail },
599
+ ...item.header === void 0 ? {} : { header: item.header },
600
+ ...item.options === void 0 ? {} : { options: item.options },
601
+ ...item.multiSelect === void 0 ? {} : { multiSelect: item.multiSelect },
602
+ ...item.intent === void 0 ? {} : { intent: item.intent }
603
+ }));
604
+ const fromNotch = new Promise((resolve) => {
605
+ this.pending.set(id, {
606
+ kind: "ask",
607
+ id,
608
+ sessionId,
609
+ questions,
610
+ resolve
611
+ });
612
+ this.bump();
613
+ request.signal?.addEventListener("abort", () => {
614
+ if (!this.pending.delete(id)) return;
615
+ this.bump();
616
+ }, { once: true });
617
+ });
618
+ return Promise.race([fromNotch, Promise.resolve().then(next)]).finally(() => {
619
+ downstream.abort(/* @__PURE__ */ new Error("Question settled through another answerer"));
620
+ if (originalSignal === void 0) delete request.signal;
621
+ else request.signal = originalSignal;
622
+ if (this.pending.delete(id)) this.bump();
623
+ });
624
+ }
625
+ isBusy(session) {
626
+ return this.ctx.agents.get(session.id)?.status === "running";
627
+ }
628
+ conversationBusy(owner, sessions) {
629
+ for (const member of sessions.values()) if (conversationOwner(member, sessions)?.id === owner.id && (this.isBusy(member) || this.hasSubagentJob(member))) return true;
630
+ return false;
631
+ }
632
+ hasSubagentJob(session) {
633
+ const agent = this.ctx.agents.get(session.id);
634
+ if (!agent) return false;
635
+ return this.ctx.get("jobs")?.list(agent.id).some((job) => job.owner === session.id && job.kind === "subagent" && (job.status === "running" || job.status === "stopping")) ?? false;
636
+ }
637
+ rowFor(session, members) {
638
+ const folded = foldSession(session);
639
+ const child = isChildSession(session);
640
+ const lastSeen = this.seen[session.id];
641
+ const busy = members.some((member) => this.isBusy(member) || this.hasSubagentJob(member));
642
+ if (folded.busy) this.pendingUnread.delete(session.id);
643
+ else if (this.running.has(session.id) && folded.lastTurn) this.pendingUnread.add(session.id);
644
+ const unread = !(lastSeen !== void 0 && (folded.lastTurn === void 0 || lastSeen >= folded.lastTurn.at)) && !busy && (this.pendingUnread.has(session.id) || this.browserCompletions.has(session.id));
645
+ const memberIds = new Set(members.map((member) => member.id));
646
+ const approval = this.heldApproval(memberIds);
647
+ const ask = this.heldAsk(memberIds);
648
+ if (!busy && !unread && !approval && !ask) return void 0;
649
+ const title = this.titleOf(session);
650
+ const row = {
651
+ id: session.id,
652
+ title,
653
+ child,
654
+ busy,
655
+ unread
656
+ };
657
+ if (folded.lastTurn && !busy) row.lastTurn = folded.lastTurn;
658
+ if (approval) row.approval = approval;
659
+ if (ask) row.ask = ask;
660
+ return row;
661
+ }
662
+ heldApproval(sessionIds) {
663
+ for (const held of this.pending.values()) if (held.kind === "approval" && sessionIds.has(held.sessionId)) return {
664
+ id: held.id,
665
+ toolName: held.toolName,
666
+ ...held.reason === void 0 ? {} : { reason: held.reason }
667
+ };
668
+ }
669
+ heldAsk(sessionIds) {
670
+ for (const held of this.pending.values()) if (held.kind === "ask" && sessionIds.has(held.sessionId)) return {
671
+ id: held.id,
672
+ questions: held.questions
673
+ };
674
+ }
675
+ titleOf(session) {
676
+ const title = this.ctx.get("sessionTitle")?.get(session)?.title?.trim();
677
+ if (title) return title;
678
+ return session.id.slice(0, 8);
679
+ }
680
+ };
681
+ //#endregion
682
+ //#region src/http.ts
683
+ const PREFIX = "/dsh-notch";
684
+ function isLoopback(req) {
685
+ const ip = req.socket.remoteAddress ?? "";
686
+ return ip === "127.0.0.1" || ip === "::1" || ip === ":ffff:127.0.0.1" || ip === "::ffff:127.0.0.1";
687
+ }
688
+ function authorized(req, token) {
689
+ if (req.headers.authorization === `Bearer ${token}`) return true;
690
+ try {
691
+ return new URL(req.url ?? "/", "http://127.0.0.1").searchParams.get("token") === token;
692
+ } catch {
693
+ return false;
694
+ }
695
+ }
696
+ /**
697
+ * Browser-side trust for pages served by this same Host: loopback remote,
698
+ * never cross-site, and a matching Origin when one is present. Same rule the
699
+ * shipped workspace file panel uses — no secret ever lands in page JS.
700
+ */
701
+ function browserTrusted(req) {
702
+ if (!isLoopback(req)) return false;
703
+ if (req.headers["sec-fetch-site"] === "cross-site") return false;
704
+ const host = req.headers.host;
705
+ if (host === void 0) return false;
706
+ const origin = req.headers.origin;
707
+ if (origin === void 0) return true;
708
+ try {
709
+ return new URL(origin).host === new URL(`http://${host}`).host;
710
+ } catch {
711
+ return false;
712
+ }
713
+ }
714
+ /** Accept same-origin Web writes and requests forwarded by the Desktop shell. */
715
+ function sidebarTrusted(req, origin) {
716
+ if (!browserTrusted(req) || !req.headers["content-type"]?.startsWith("application/json")) return false;
717
+ if (req.headers.origin !== void 0) return true;
718
+ return req.headers["x-dsh-notch-client"] === "1" && req.headers["sec-fetch-site"] === void 0 && req.headers.host === new URL(origin).host;
719
+ }
720
+ function send(res, status, body) {
721
+ const json = JSON.stringify(body);
722
+ res.writeHead(status, {
723
+ "content-type": "application/json; charset=utf-8",
724
+ "cache-control": "no-store"
725
+ });
726
+ res.end(json);
727
+ }
728
+ function readBody(req, limit = 64 * 1024) {
729
+ return new Promise((resolve, reject) => {
730
+ const chunks = [];
731
+ let size = 0;
732
+ req.on("data", (chunk) => {
733
+ size += chunk.length;
734
+ if (size > limit) {
735
+ reject(/* @__PURE__ */ new Error("body too large"));
736
+ req.destroy();
737
+ return;
738
+ }
739
+ chunks.push(chunk);
740
+ });
741
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
742
+ req.on("error", reject);
743
+ });
744
+ }
745
+ function attachHttp(ctx, board, token, origin) {
746
+ const streams = /* @__PURE__ */ new Map();
747
+ const push = () => {
748
+ const payload = `data: ${JSON.stringify(board.snapshot(origin))}\n\n`;
749
+ for (const [id, res] of streams) try {
750
+ res.write(payload);
751
+ } catch {
752
+ streams.delete(id);
753
+ }
754
+ };
755
+ const stopListen = board.onChange(push);
756
+ const handler = async (req, res) => {
757
+ const path = new URL(req.url ?? "/", "http://127.0.0.1").pathname;
758
+ const method = req.method ?? "GET";
759
+ if (method === "GET" && path === `${PREFIX}/pending-focus`) {
760
+ if (!browserTrusted(req)) {
761
+ send(res, 403, {
762
+ ok: false,
763
+ error: "forbidden"
764
+ });
765
+ return;
766
+ }
767
+ send(res, 200, {
768
+ ok: true,
769
+ focus: board.peekFocus()
770
+ });
771
+ return;
772
+ }
773
+ if (method === "POST" && path === `${PREFIX}/sidebar`) {
774
+ if (!sidebarTrusted(req, origin)) {
775
+ send(res, 403, {
776
+ ok: false,
777
+ error: "forbidden"
778
+ });
779
+ return;
780
+ }
781
+ const ok = board.syncSidebar(JSON.parse(await readBody(req, 512 * 1024)));
782
+ send(res, ok ? 200 : 400, { ok });
783
+ return;
784
+ }
785
+ if (!isLoopback(req) || !authorized(req, token)) {
786
+ send(res, 403, {
787
+ ok: false,
788
+ error: "forbidden"
789
+ });
790
+ return;
791
+ }
792
+ if (method === "GET" && path === `${PREFIX}/status`) {
793
+ send(res, 200, board.snapshot(origin));
794
+ return;
795
+ }
796
+ if (method === "GET" && path === `${PREFIX}/diagnostics`) {
797
+ send(res, 200, board.diagnostics());
798
+ return;
799
+ }
800
+ if (method === "GET" && path === `${PREFIX}/events`) {
801
+ const id = randomUUID();
802
+ res.writeHead(200, {
803
+ "content-type": "text/event-stream",
804
+ "cache-control": "no-cache",
805
+ connection: "keep-alive"
806
+ });
807
+ streams.set(id, res);
808
+ res.write(`data: ${JSON.stringify(board.snapshot(origin))}\n\n`);
809
+ req.on("close", () => {
810
+ streams.delete(id);
811
+ });
812
+ return;
813
+ }
814
+ if (method === "POST" && path === `${PREFIX}/seen`) {
815
+ const body = JSON.parse(await readBody(req));
816
+ if (body.all) {
817
+ board.markAllSeen();
818
+ send(res, 200, { ok: true });
819
+ return;
820
+ }
821
+ if (!body.sessionId) {
822
+ send(res, 400, {
823
+ ok: false,
824
+ error: "sessionId required"
825
+ });
826
+ return;
827
+ }
828
+ board.markSeen(body.sessionId);
829
+ send(res, 200, { ok: true });
830
+ return;
831
+ }
832
+ if (method === "POST" && path === `${PREFIX}/approve`) {
833
+ const body = JSON.parse(await readBody(req));
834
+ if (!body.id || body.outcome !== "allowed-once" && body.outcome !== "rejected") {
835
+ send(res, 400, {
836
+ ok: false,
837
+ error: "id and outcome required"
838
+ });
839
+ return;
840
+ }
841
+ if (!board.decideApproval(body.id, body.outcome)) {
842
+ send(res, 404, {
843
+ ok: false,
844
+ error: "not pending"
845
+ });
846
+ return;
847
+ }
848
+ send(res, 200, { ok: true });
849
+ return;
850
+ }
851
+ if (method === "POST" && path === `${PREFIX}/answer`) {
852
+ const body = JSON.parse(await readBody(req));
853
+ if (!body.id || !Array.isArray(body.answers)) {
854
+ send(res, 400, {
855
+ ok: false,
856
+ error: "id and answers required"
857
+ });
858
+ return;
859
+ }
860
+ if (!board.answerAsk(body.id, body.answers)) {
861
+ send(res, 404, {
862
+ ok: false,
863
+ error: "not pending"
864
+ });
865
+ return;
866
+ }
867
+ send(res, 200, { ok: true });
868
+ return;
869
+ }
870
+ if (method === "POST" && path === `${PREFIX}/focus`) {
871
+ const body = JSON.parse(await readBody(req));
872
+ if (!body.sessionId) {
873
+ send(res, 400, {
874
+ ok: false,
875
+ error: "sessionId required"
876
+ });
877
+ return;
878
+ }
879
+ if (!board.requestFocus(body.sessionId)) {
880
+ send(res, 404, {
881
+ ok: false,
882
+ error: "unknown session"
883
+ });
884
+ return;
885
+ }
886
+ send(res, 200, { ok: true });
887
+ return;
888
+ }
889
+ send(res, 404, {
890
+ ok: false,
891
+ error: "not found"
892
+ });
893
+ };
894
+ const disposeRoute = ctx.webServer.register({
895
+ kind: "prefix",
896
+ path: PREFIX,
897
+ handler: (req, res) => {
898
+ handler(req, res).catch((error) => {
899
+ if (!res.headersSent) send(res, 400, {
900
+ ok: false,
901
+ error: String(error)
902
+ });
903
+ });
904
+ }
905
+ });
906
+ return () => {
907
+ stopListen();
908
+ for (const res of streams.values()) try {
909
+ res.end();
910
+ } catch {}
911
+ streams.clear();
912
+ disposeRoute();
913
+ };
914
+ }
915
+ //#endregion
916
+ //#region src/dsh-notch.ts
917
+ const name = "dsh-notch";
918
+ const inject = [
919
+ "sessions",
920
+ "webServer",
921
+ "approval",
922
+ "userQuestions",
923
+ "agents"
924
+ ];
925
+ const Config = Schema.object({ helperPath: Schema.string().default("").description("Optional installed native Notch executable; managed for this Host lifetime.") });
926
+ function apply(ctx, config = {}) {
927
+ console.log("[my-plugins/dsh-notch] loaded");
928
+ const board = new Board(ctx);
929
+ const token = loadOrCreateToken();
930
+ const origin = `http://${ctx.webServer.host}:${String(ctx.webServer.port)}`;
931
+ writeRuntime({
932
+ origin,
933
+ token,
934
+ pid: process.pid,
935
+ writtenAt: Date.now()
936
+ });
937
+ if (config.helperPath) {
938
+ if (!existsSync(config.helperPath)) throw new Error("Configured Notch helper does not exist");
939
+ ctx.effect(() => {
940
+ const log = createWriteStream(join(DIR, "helper.log"), {
941
+ flags: "a",
942
+ mode: 384
943
+ });
944
+ const helper = superviseNotch({
945
+ bin: config.helperPath,
946
+ pidPath: join(DIR, "helper.pid"),
947
+ log,
948
+ env: {
949
+ ...process.env,
950
+ DSH_NOTCH_RUNTIME_FILE: RUNTIME
951
+ }
952
+ });
953
+ return () => {
954
+ helper.stop();
955
+ log.end();
956
+ };
957
+ }, "dsh-notch: native helper");
958
+ }
959
+ ctx.effect(() => attachHttp(ctx, board, token, origin), "dsh-notch: http");
960
+ const notify = debounce(() => board.notify(), 80);
961
+ ctx.effect(() => notify.dispose, "dsh-notch: notification timer");
962
+ ctx.effect(() => ctx.on("session/created", notify), "dsh-notch: created");
963
+ ctx.effect(() => ctx.on("session/disposed", notify), "dsh-notch: disposed");
964
+ ctx.effect(() => ctx.on("session/event", (session, event) => {
965
+ const type = String(event.type);
966
+ if (type === "turn/end") board.noteTurnEnd(session, event.time);
967
+ if (type === "turn/start" || type === "turn/end" || type === "session/title") notify();
968
+ if (type === "turn/start" || type === "user/message" && event.data?.source === "user") board.markSeen(session.id);
969
+ }), "dsh-notch: events");
970
+ ctx.effect(() => ctx.on("agent/status", notify), "dsh-notch: agent-status");
971
+ ctx.on("user-questions/request", (request, next) => board.holdAsk(request, next), { prepend: true });
972
+ }
973
+ function debounce(fn, ms) {
974
+ let timer;
975
+ const notify = () => {
976
+ if (timer) clearTimeout(timer);
977
+ timer = setTimeout(() => {
978
+ timer = void 0;
979
+ fn();
980
+ }, ms);
981
+ };
982
+ return Object.assign(notify, { dispose: () => {
983
+ if (timer) clearTimeout(timer);
984
+ timer = void 0;
985
+ } });
986
+ }
987
+ //#endregion
988
+ export { Config, apply, inject, name };
989
+
990
+ //# sourceMappingURL=index.mjs.map