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.
- package/README.en.md +58 -47
- package/README.md +60 -55
- package/cordis.patch.yml +3 -0
- package/desktop/README.md +29 -0
- package/desktop/notch-lifecycle.mjs +120 -0
- package/desktop/restart-probe.mjs +61 -0
- package/docs/desktop-017rc2.md +23 -0
- package/docs/elastic-edge-dock.md +127 -0
- package/docs/motion-audit.md +1 -1
- package/docs/releases/v0.3.2.md +38 -0
- package/docs/releases/v0.3.3.md +9 -0
- package/docs/subagent-compatibility.md +51 -0
- package/docs/verification/elastic-algorithm-timing.json +337 -0
- package/docs/verification/elastic-attention.json +44 -0
- package/docs/verification/elastic-frame-timing.json +240 -0
- package/docs/verification/elastic-tension.json +59 -0
- package/docs/verification/elastic-tip-taper.json +181 -0
- package/dshx.yml +2 -0
- package/lib/client.js +112 -0
- package/lib/client.js.map +1 -0
- package/lib/index.mjs +990 -0
- package/lib/index.mjs.map +1 -0
- package/package.json +66 -10
- package/src/board.ts +178 -35
- package/src/client/index.ts +104 -0
- package/src/dsh-notch.ts +19 -2
- package/src/http.ts +17 -2
- package/src/session-state.ts +15 -1
- package/src/store.ts +3 -3
- package/src/types.ts +3 -0
- package/BASELINE.md +0 -13
- package/DESIGN.md +0 -34
- package/cordis.yml +0 -5
- package/macos/Package.swift +0 -17
- package/macos/STATUS-MOTION.md +0 -49
- package/macos/Sources/Client.swift +0 -139
- package/macos/Sources/IdleRobot.swift +0 -423
- package/macos/Sources/NotchMarkdown.swift +0 -266
- package/macos/Sources/Panel.swift +0 -153
- package/macos/Sources/Resources/Idle/balance.json +0 -1
- package/macos/Sources/Resources/Idle/blink.json +0 -1
- package/macos/Sources/Resources/Idle/cube-in.json +0 -1
- package/macos/Sources/Resources/Idle/dance.json +0 -1
- package/macos/Sources/Resources/Idle/hop.json +0 -1
- package/macos/Sources/Resources/Idle/nod.json +0 -1
- package/macos/Sources/Resources/Idle/satellite-in.json +0 -1
- package/macos/Sources/Resources/Idle/satellite-out.json +0 -1
- package/macos/Sources/Resources/Idle/scan.json +0 -1
- package/macos/Sources/Resources/Idle/sleep.json +0 -1
- package/macos/Sources/Resources/Idle/sneeze.json +0 -1
- package/macos/Sources/Resources/Idle/stretch.json +0 -1
- package/macos/Sources/Resources/Idle/tilt.json +0 -1
- package/macos/Sources/ReviewStudio.swift +0 -337
- package/macos/Sources/RootView.swift +0 -1093
- package/macos/Sources/StatusOrbit.swift +0 -642
- package/macos/Sources/main.swift +0 -153
- package/macos/Tests/ExpandedHeightProbe.swift +0 -108
- package/macos/Tests/GeometryProbe.swift +0 -98
- package/macos/Tests/IdleProbe.swift +0 -294
- package/macos/Tests/MotionProbe.swift +0 -106
- package/macos/Tests/OutcomeProbe.swift +0 -133
- package/macos/Tests/ScrollbarProbe.swift +0 -91
- package/macos/Tests/expanded-height.sh +0 -13
- package/macos/Tests/geometry.sh +0 -14
- package/macos/Tests/idle.sh +0 -14
- package/macos/Tests/motion.sh +0 -14
- package/macos/Tests/outcome.sh +0 -14
- package/macos/Tests/scrollbar.sh +0 -13
- package/tests/answer-lifecycle.test.mjs +0 -23
- package/tests/board.test.mjs +0 -7
- package/tests/browse-sync.test.mjs +0 -20
- package/tests/mirror.test.mjs +0 -4
- package/tests/peer-range.test.mjs +0 -21
- package/tests/session-state.test.mjs +0 -54
- package/tests/unread-hold.test.mjs +0 -90
- package/tools/desktop-shell/README.md +0 -56
- package/tools/desktop-shell/child-log.mjs +0 -6
- package/tools/desktop-shell/child-log.test.mjs +0 -14
- package/tools/desktop-shell/package-recovery.py +0 -78
- package/tools/desktop-shell/recovery-probe.cjs +0 -50
- package/tools/desktop-shell/renderer-recovery.mjs +0 -95
- package/tools/desktop-shell/renderer-recovery.test.mjs +0 -24
- package/tools/diagnostics/completion-watch.py +0 -101
- package/tools/diagnostics/completion-watch.test.py +0 -17
- package/tools/diagnostics/renderer-diagnostics.mjs +0 -56
- package/tools/diagnostics/renderer-diagnostics.test.mjs +0 -32
- package/tools/idle/LICENSE.open-bot-motion +0 -21
- package/tools/idle/export.mjs +0 -40
- package/tools/idle/index.html +0 -4
- package/tools/idle/motions.js +0 -83
- package/tools/idle/original.js +0 -1673
- package/tools/idle/renderer.js +0 -1673
- package/tools/recording/Client.swift +0 -76
- package/tools/recording/DemoCatalog.swift +0 -174
- package/tools/recording/README.md +0 -23
- package/tools/recording/build.sh +0 -41
- package/tools/recording/main.swift +0 -100
- package/tools/review/ReviewStudio.swift +0 -236
- package/tools/review/audit.sh +0 -19
- package/tools/review/build.sh +0 -7
- package/tools/review/main.swift +0 -158
- 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
|