dsh-notch 0.3.1

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 (87) hide show
  1. package/BASELINE.md +13 -0
  2. package/DESIGN.md +34 -0
  3. package/LICENSE +21 -0
  4. package/README.en.md +136 -0
  5. package/README.md +145 -0
  6. package/cordis.yml +5 -0
  7. package/docs/motion-audit.md +65 -0
  8. package/docs/motion-continuity.md +77 -0
  9. package/docs/releases/v0.3.0.md +20 -0
  10. package/dshx.yml +14 -0
  11. package/macos/Package.swift +17 -0
  12. package/macos/STATUS-MOTION.md +49 -0
  13. package/macos/Sources/Client.swift +139 -0
  14. package/macos/Sources/IdleRobot.swift +423 -0
  15. package/macos/Sources/NotchMarkdown.swift +266 -0
  16. package/macos/Sources/Panel.swift +153 -0
  17. package/macos/Sources/Resources/Idle/balance.json +1 -0
  18. package/macos/Sources/Resources/Idle/blink.json +1 -0
  19. package/macos/Sources/Resources/Idle/cube-in.json +1 -0
  20. package/macos/Sources/Resources/Idle/dance.json +1 -0
  21. package/macos/Sources/Resources/Idle/hop.json +1 -0
  22. package/macos/Sources/Resources/Idle/nod.json +1 -0
  23. package/macos/Sources/Resources/Idle/satellite-in.json +1 -0
  24. package/macos/Sources/Resources/Idle/satellite-out.json +1 -0
  25. package/macos/Sources/Resources/Idle/scan.json +1 -0
  26. package/macos/Sources/Resources/Idle/sleep.json +1 -0
  27. package/macos/Sources/Resources/Idle/sneeze.json +1 -0
  28. package/macos/Sources/Resources/Idle/stretch.json +1 -0
  29. package/macos/Sources/Resources/Idle/tilt.json +1 -0
  30. package/macos/Sources/ReviewStudio.swift +337 -0
  31. package/macos/Sources/RootView.swift +1093 -0
  32. package/macos/Sources/StatusOrbit.swift +642 -0
  33. package/macos/Sources/main.swift +153 -0
  34. package/macos/Tests/ExpandedHeightProbe.swift +108 -0
  35. package/macos/Tests/GeometryProbe.swift +98 -0
  36. package/macos/Tests/IdleProbe.swift +294 -0
  37. package/macos/Tests/MotionProbe.swift +106 -0
  38. package/macos/Tests/OutcomeProbe.swift +133 -0
  39. package/macos/Tests/ScrollbarProbe.swift +91 -0
  40. package/macos/Tests/expanded-height.sh +13 -0
  41. package/macos/Tests/geometry.sh +14 -0
  42. package/macos/Tests/idle.sh +14 -0
  43. package/macos/Tests/motion.sh +14 -0
  44. package/macos/Tests/outcome.sh +14 -0
  45. package/macos/Tests/scrollbar.sh +13 -0
  46. package/package.json +46 -0
  47. package/src/board.ts +299 -0
  48. package/src/browse-sync.ts +59 -0
  49. package/src/dsh-notch.ts +60 -0
  50. package/src/http.ts +215 -0
  51. package/src/session-state.ts +31 -0
  52. package/src/store.ts +49 -0
  53. package/src/types.ts +65 -0
  54. package/tests/answer-lifecycle.test.mjs +23 -0
  55. package/tests/board.test.mjs +7 -0
  56. package/tests/browse-sync.test.mjs +20 -0
  57. package/tests/mirror.test.mjs +4 -0
  58. package/tests/peer-range.test.mjs +21 -0
  59. package/tests/session-state.test.mjs +54 -0
  60. package/tests/unread-hold.test.mjs +90 -0
  61. package/tools/desktop-shell/README.md +56 -0
  62. package/tools/desktop-shell/child-log.mjs +6 -0
  63. package/tools/desktop-shell/child-log.test.mjs +14 -0
  64. package/tools/desktop-shell/package-recovery.py +78 -0
  65. package/tools/desktop-shell/recovery-probe.cjs +50 -0
  66. package/tools/desktop-shell/renderer-recovery.mjs +95 -0
  67. package/tools/desktop-shell/renderer-recovery.test.mjs +24 -0
  68. package/tools/diagnostics/completion-watch.py +101 -0
  69. package/tools/diagnostics/completion-watch.test.py +17 -0
  70. package/tools/diagnostics/renderer-diagnostics.mjs +56 -0
  71. package/tools/diagnostics/renderer-diagnostics.test.mjs +32 -0
  72. package/tools/idle/LICENSE.open-bot-motion +21 -0
  73. package/tools/idle/export.mjs +40 -0
  74. package/tools/idle/index.html +4 -0
  75. package/tools/idle/motions.js +83 -0
  76. package/tools/idle/original.js +1673 -0
  77. package/tools/idle/renderer.js +1673 -0
  78. package/tools/recording/Client.swift +76 -0
  79. package/tools/recording/DemoCatalog.swift +174 -0
  80. package/tools/recording/README.md +23 -0
  81. package/tools/recording/build.sh +41 -0
  82. package/tools/recording/main.swift +100 -0
  83. package/tools/review/ReviewStudio.swift +236 -0
  84. package/tools/review/audit.sh +19 -0
  85. package/tools/review/build.sh +7 -0
  86. package/tools/review/main.swift +158 -0
  87. package/tools/review/render-audit.swift +32 -0
package/src/board.ts ADDED
@@ -0,0 +1,299 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import type { Context } from '@deepseek-ai/cordis'
3
+ import type { Session } from '@deepseek-ai/dsh-session'
4
+ import type { ApprovalOutcome, ApprovalRequest } from '@deepseek-ai/dsh-user-approval'
5
+ import type {
6
+ AskUserQuestionAnswer,
7
+ AskUserQuestionRequest,
8
+ } from '@deepseek-ai/dsh-user-questions'
9
+ import { foldSession, isChildSession } from './session-state.ts'
10
+ import { loadSeen, saveSeen } from './store.ts'
11
+ import type {
12
+ ApprovalOutcomeWire,
13
+ NotchAnswerItem,
14
+ NotchApproval,
15
+ NotchAsk,
16
+ NotchFocus,
17
+ NotchQuestion,
18
+ NotchRow,
19
+ NotchSnapshot,
20
+ } from './types.ts'
21
+
22
+ interface HeldApproval {
23
+ kind: 'approval'
24
+ id: string
25
+ sessionId: string
26
+ toolName: string
27
+ reason?: string
28
+ resolve: (outcome: ApprovalOutcome) => void
29
+ }
30
+
31
+ interface HeldAsk {
32
+ kind: 'ask'
33
+ id: string
34
+ sessionId: string
35
+ questions: NotchQuestion[]
36
+ resolve: (answer: AskUserQuestionAnswer) => void
37
+ }
38
+
39
+ type Held = HeldApproval | HeldAsk
40
+
41
+ interface SidebarRow { id: string; title: string; completed: boolean; running: boolean }
42
+ export class Board {
43
+ private sidebar: { clientId: string; at: number; rows: SidebarRow[] } | undefined
44
+
45
+ syncSidebar(input: unknown): boolean {
46
+ if (!input || typeof input !== 'object') return false
47
+ const data = input as { clientId?: unknown; focused?: unknown; rows?: unknown }
48
+ if (typeof data.clientId !== 'string' || !Array.isArray(data.rows) || data.rows.length > 1000) return false
49
+ const rows: SidebarRow[] = []
50
+ for (const row of data.rows) {
51
+ if (!row || typeof row.id !== 'string' || !row.id.startsWith('session-') || typeof row.title !== 'string' || typeof row.completed !== 'boolean' || typeof row.running !== 'boolean') return false
52
+ rows.push({ id: row.id, title: row.title.slice(0, 512), completed: row.completed, running: row.running })
53
+ }
54
+ // A background browser cannot overwrite the last foreground page's state.
55
+ if (this.sidebar && this.sidebar.clientId !== data.clientId && data.focused !== true && Date.now() - this.sidebar.at < 5000) return true
56
+ this.sidebar = { clientId: data.clientId, at: Date.now(), rows }
57
+ this.bump()
58
+ return true
59
+ }
60
+
61
+ private sidebarRows(): SidebarRow[] | undefined {
62
+ return this.sidebar && Date.now() - this.sidebar.at < 5000 ? this.sidebar.rows : undefined
63
+ }
64
+
65
+ private readonly pending = new Map<string, Held>()
66
+ private readonly seen = loadSeen()
67
+ private readonly running = new Set<string>()
68
+ private readonly pendingUnread = new Set<string>()
69
+ private readonly listeners = new Set<() => void>()
70
+ private focus: NotchFocus | null = null
71
+
72
+ constructor(private readonly ctx: Context) {}
73
+
74
+ onChange(fn: () => void): () => void {
75
+ this.listeners.add(fn)
76
+ return () => { this.listeners.delete(fn) }
77
+ }
78
+
79
+ notify(): void {
80
+ this.bump()
81
+ }
82
+
83
+ private bump(): void {
84
+ for (const fn of this.listeners) {
85
+ try { fn() } catch (error) { this.ctx.logger.warn('dsh-notch: subscriber failed: %s', String(error)) }
86
+ }
87
+ }
88
+
89
+ snapshot(origin: string): NotchSnapshot {
90
+ const rows: NotchRow[] = []
91
+ for (const session of this.ctx.sessions.list()) {
92
+ const row = this.rowFor(session)
93
+ if (row) rows.push(row)
94
+ }
95
+ for (const source of this.sidebarRows() ?? []) {
96
+ if (rows.some(row => row.id === source.id) || (!source.completed && !source.running)) continue
97
+ if (source.completed && this.seen[source.id] !== undefined) continue
98
+ rows.push({ id: source.id, title: source.title, child: false, busy: source.running, unread: source.completed })
99
+ }
100
+ rows.sort((a, b) => Number(Boolean(b.approval || b.ask)) - Number(Boolean(a.approval || a.ask))
101
+ || Number(b.busy) - Number(a.busy)
102
+ || Number(b.unread) - Number(a.unread)
103
+ || (b.lastTurn?.at ?? 0) - (a.lastTurn?.at ?? 0))
104
+ this.running = new Set(
105
+ this.ctx.sessions.list().filter((session) => this.isBusy(session)).map((session) => session.id),
106
+ )
107
+ return { ok: true, generatedAt: Date.now(), origin, rows, sidebarSyncedAt: this.sidebarRows() ? this.sidebar?.at : undefined }
108
+ }
109
+
110
+ markSeen(sessionId: string): void {
111
+ this.pendingUnread.delete(sessionId)
112
+ this.seen[sessionId] = Date.now()
113
+ saveSeen(this.seen)
114
+ this.bump()
115
+ }
116
+
117
+ markAllSeen(): void {
118
+ this.pendingUnread.clear()
119
+ const now = Date.now()
120
+ for (const session of this.ctx.sessions.list()) {
121
+ this.seen[session.id] = now
122
+ }
123
+ saveSeen(this.seen)
124
+ this.bump()
125
+ }
126
+
127
+ /**
128
+ * Record a "show this session in a DSH UI" wish from the native helper.
129
+ * Returns false for unknown sessions. The wish expires after 60s; every
130
+ * open DSH page consumes it idempotently, so no per-client cursor is kept.
131
+ */
132
+ requestFocus(sessionId: string): boolean {
133
+ const known = this.ctx.sessions.list().some((session) => session.id === sessionId)
134
+ if (!known && !this.sidebarRows()?.some(row => row.id === sessionId)) return false
135
+ this.focus = { sessionId, at: Date.now() }
136
+ this.bump()
137
+ return true
138
+ }
139
+
140
+ peekFocus(): NotchFocus | null {
141
+ if (this.focus === null) return null
142
+ if (Date.now() - this.focus.at > 60_000) {
143
+ this.focus = null
144
+ return null
145
+ }
146
+ return this.focus
147
+ }
148
+
149
+ decideApproval(id: string, outcome: ApprovalOutcomeWire): boolean {
150
+ const held = this.pending.get(id)
151
+ if (!held || held.kind !== 'approval') return false
152
+ this.pending.delete(id)
153
+ this.seen[held.sessionId] = Date.now()
154
+ saveSeen(this.seen)
155
+ held.resolve(outcome)
156
+ this.bump()
157
+ return true
158
+ }
159
+
160
+ answerAsk(id: string, answers: NotchAnswerItem[]): boolean {
161
+ const held = this.pending.get(id)
162
+ if (!held || held.kind !== 'ask') return false
163
+ this.pending.delete(id)
164
+ this.seen[held.sessionId] = Date.now()
165
+ saveSeen(this.seen)
166
+ held.resolve({ answers })
167
+ this.bump()
168
+ return true
169
+ }
170
+
171
+ holdApproval(request: ApprovalRequest, next: () => Promise<ApprovalOutcome>): Promise<ApprovalOutcome> {
172
+ const sessionId = String(request.agent.id)
173
+ const id = randomUUID()
174
+ const fromNotch = new Promise<ApprovalOutcome>((resolve) => {
175
+ const held: HeldApproval = {
176
+ kind: 'approval',
177
+ id,
178
+ sessionId,
179
+ toolName: request.toolName,
180
+ resolve,
181
+ }
182
+ if (request.reason) held.reason = request.reason
183
+ this.pending.set(id, held)
184
+ this.bump()
185
+ request.signal?.addEventListener('abort', () => {
186
+ if (!this.pending.delete(id)) return
187
+ this.bump()
188
+ }, { once: true })
189
+ })
190
+ return Promise.race([fromNotch, next()]).finally(() => {
191
+ if (this.pending.delete(id)) this.bump()
192
+ })
193
+ }
194
+
195
+ holdAsk(request: AskUserQuestionRequest, next: () => Promise<AskUserQuestionAnswer>): Promise<AskUserQuestionAnswer> {
196
+ const sessionId = request.agent ? String(request.agent.id) : ''
197
+ if (!sessionId) return next()
198
+ const originalSignal = request.signal
199
+ const downstream = new AbortController()
200
+ request.signal = originalSignal
201
+ ? AbortSignal.any([originalSignal, downstream.signal])
202
+ : downstream.signal
203
+ const id = randomUUID()
204
+ const questions: NotchQuestion[] = request.questions.map((item) => ({
205
+ id: item.id,
206
+ question: item.question,
207
+ ...(item.detail === undefined ? {} : { detail: item.detail }),
208
+ ...(item.header === undefined ? {} : { header: item.header }),
209
+ ...(item.options === undefined ? {} : { options: item.options }),
210
+ ...(item.multiSelect === undefined ? {} : { multiSelect: item.multiSelect }),
211
+ ...(item.intent === undefined ? {} : { intent: item.intent }),
212
+ }))
213
+ const fromNotch = new Promise<AskUserQuestionAnswer>((resolve) => {
214
+ this.pending.set(id, { kind: 'ask', id, sessionId, questions, resolve })
215
+ this.bump()
216
+ request.signal?.addEventListener('abort', () => {
217
+ if (!this.pending.delete(id)) return
218
+ this.bump()
219
+ }, { once: true })
220
+ })
221
+ return Promise.race([fromNotch, Promise.resolve().then(next)]).finally(() => {
222
+ // The losing Web answerer must end too, so its pending interaction unmounts.
223
+ // Cancel only this delegated wait, never the owning agent's signal.
224
+ downstream.abort(new Error('Question settled through another answerer'))
225
+ if (originalSignal === undefined) delete request.signal
226
+ else request.signal = originalSignal
227
+ if (this.pending.delete(id)) this.bump()
228
+ })
229
+ }
230
+
231
+ private isBusy(session: Session): boolean {
232
+ return this.ctx.agents.get(session.id)?.status === 'running'
233
+ }
234
+
235
+ private rowFor(session: Session): NotchRow | undefined {
236
+ const folded = foldSession(session)
237
+ const child = isChildSession(session)
238
+ const lastSeen = this.seen[session.id]
239
+ const busy = this.isBusy(session)
240
+ if (busy) {
241
+ this.pendingUnread.delete(session.id)
242
+ } else if (this.running.has(session.id) && folded.lastTurn && !folded.lastTurn.failed) {
243
+ this.pendingUnread.add(session.id)
244
+ }
245
+ const dismissed = lastSeen !== undefined && (folded.lastTurn === undefined || lastSeen >= folded.lastTurn.at)
246
+ const mirror = this.sidebarRows()
247
+ const unread = dismissed
248
+ ? false
249
+ : mirror !== undefined
250
+ ? mirror.some(row => row.id === session.id && row.completed)
251
+ : this.pendingUnread.has(session.id)
252
+ || (folded.lastTurn !== undefined && lastSeen !== undefined && folded.lastTurn.at > lastSeen)
253
+ const approval = this.heldApproval(session.id)
254
+ const ask = this.heldAsk(session.id)
255
+ if (!busy && !unread && !approval && !ask) return undefined
256
+ if (child && !approval && !ask && !busy) return undefined
257
+ const title = this.titleOf(session)
258
+ const row: NotchRow = {
259
+ id: session.id,
260
+ title,
261
+ child,
262
+ busy,
263
+ unread,
264
+ }
265
+ if (folded.lastTurn && !busy) row.lastTurn = folded.lastTurn
266
+ if (approval) row.approval = approval
267
+ if (ask) row.ask = ask
268
+ return row
269
+ }
270
+
271
+ private heldApproval(sessionId: string): NotchApproval | undefined {
272
+ for (const held of this.pending.values()) {
273
+ if (held.kind === 'approval' && held.sessionId === sessionId) {
274
+ return {
275
+ id: held.id,
276
+ toolName: held.toolName,
277
+ ...(held.reason === undefined ? {} : { reason: held.reason }),
278
+ }
279
+ }
280
+ }
281
+ return undefined
282
+ }
283
+
284
+ private heldAsk(sessionId: string): NotchAsk | undefined {
285
+ for (const held of this.pending.values()) {
286
+ if (held.kind === 'ask' && held.sessionId === sessionId) {
287
+ return { id: held.id, questions: held.questions }
288
+ }
289
+ }
290
+ return undefined
291
+ }
292
+
293
+ private titleOf(session: Session): string {
294
+ const service = this.ctx.get('sessionTitle') as { get(target: Session): { title: string } | undefined } | undefined
295
+ const title = service?.get(session)?.title?.trim()
296
+ if (title) return title
297
+ return session.id.slice(0, 8)
298
+ }
299
+ }
@@ -0,0 +1,59 @@
1
+ /** Observe browsing without changing the history transport or retaining disposed boards. */
2
+ const KEY = Symbol.for('dsh-notch.browse-sync.v1')
3
+ type Controller = Record<PropertyKey, any>
4
+ type Observer = (id: string) => void
5
+ function sessionId(request: any): string | undefined {
6
+ const value = request?.address?.sessionId ?? request?.address?.childSessionId
7
+ return typeof value === 'string' ? value : undefined
8
+ }
9
+ export function installBrowseSync(controller: Controller, observe: Observer): () => void {
10
+ let state = controller[KEY]
11
+ if (!state) {
12
+ // Older Notch versions assigned wrappers without a disposer. Only replace
13
+ // those exact known wrappers, using the verified official prototype method.
14
+ for (const name of ['page', 'follow']) {
15
+ const own = Object.getOwnPropertyDescriptor(controller, name)
16
+ const text = typeof own?.value === 'function' ? own.value.toString().replace(/\s/g, '') : ''
17
+ const legacy = name === 'follow'
18
+ ? 'function*(req,signal){constsid=getTargetSessionId(req.address);if(sid)board.markSeen(sid);returnyield*origFollow(req,signal)}'
19
+ : '(req,signal)=>{constsid=getTargetSessionId(req.address);if(sid)board.markSeen(sid);returnorigPage(req,signal)}'
20
+ if (text === legacy) {
21
+ const base = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(controller), name)?.value
22
+ if (typeof base !== 'function' || !base.toString().replace(/\s/g, '').includes(`returnthis.history.${name}(request,signal)`)) {
23
+ throw new Error(`Cannot restore known legacy Notch ${name}: official prototype changed`)
24
+ }
25
+ delete controller[name]
26
+ }
27
+ }
28
+ state = { observers: new Set<Observer>(), methods: [] as any[] }
29
+ for (const name of ['page', 'follow']) {
30
+ const descriptor = Object.getOwnPropertyDescriptor(controller, name)
31
+ const original = controller[name]
32
+ const wrapped = function(this: unknown, ...args: any[]) {
33
+ const result = Reflect.apply(original, this, args)
34
+ const id = sessionId(args[0])
35
+ if (id) for (const callback of [...state.observers] as Observer[]) {
36
+ try { callback(id) } catch { /* Optional read marker must never break history. */ }
37
+ }
38
+ return result
39
+ }
40
+ controller[name] = wrapped
41
+ state.methods.push({ name, descriptor, wrapped })
42
+ }
43
+ controller[KEY] = state
44
+ }
45
+ state.observers.add(observe)
46
+ let disposed = false
47
+ return () => {
48
+ if (disposed) return
49
+ disposed = true
50
+ state.observers.delete(observe)
51
+ if (state.observers.size) return
52
+ for (const { name, descriptor, wrapped } of state.methods) {
53
+ if (controller[name] !== wrapped) continue
54
+ if (descriptor) Object.defineProperty(controller, name, descriptor)
55
+ else delete controller[name]
56
+ }
57
+ if (controller[KEY] === state) delete controller[KEY]
58
+ }
59
+ }
@@ -0,0 +1,60 @@
1
+ import type { Context } from '@deepseek-ai/cordis'
2
+ import type { ApprovalOutcome, ApprovalRequest } from '@deepseek-ai/dsh-user-approval'
3
+ import type { AskUserQuestionAnswer, AskUserQuestionRequest } from '@deepseek-ai/dsh-user-questions'
4
+ import type {} from '@deepseek-ai/dsh-host-webserver'
5
+ import type {} from '@deepseek-ai/dsh-session'
6
+ import { Board } from './board.ts'
7
+ import { attachHttp } from './http.ts'
8
+ import { loadOrCreateToken, writeRuntime } from './store.ts'
9
+
10
+ export const name = 'dsh-notch'
11
+ export const inject = ['sessions', 'webServer', 'approval', 'userQuestions', 'agents']
12
+
13
+ export function apply(ctx: Context) {
14
+ console.log('[my-plugins/dsh-notch] loaded')
15
+ const board = new Board(ctx)
16
+ const token = loadOrCreateToken()
17
+ const origin = `http://${ctx.webServer.host}:${String(ctx.webServer.port)}`
18
+ writeRuntime({
19
+ origin,
20
+ token,
21
+ pid: process.pid,
22
+ writtenAt: Date.now(),
23
+ })
24
+
25
+ ctx.effect(() => attachHttp(ctx, board, token, origin), 'dsh-notch: http')
26
+
27
+ const notify = debounce(() => board.notify(), 80)
28
+ ctx.effect(() => notify.dispose, 'dsh-notch: notification timer')
29
+ ctx.effect(() => ctx.on('session/created', notify), 'dsh-notch: created')
30
+ ctx.effect(() => ctx.on('session/disposed', notify), 'dsh-notch: disposed')
31
+ ctx.effect(() => ctx.on('session/event', (session, event) => {
32
+ const type = String(event.type)
33
+ if (type === 'turn/start' || type === 'turn/end' || type === 'session/title') {
34
+ notify()
35
+ }
36
+ // When a turn starts or user sends a message in DSH, mark session as read
37
+ if (type === 'turn/start' || (type === 'user/message' && (event.data as Record<string, unknown>)?.source === 'user')) {
38
+ board.markSeen(session.id)
39
+ }
40
+ }), 'dsh-notch: events')
41
+ ctx.effect(() => ctx.on('agent/status', notify), 'dsh-notch: agent-status')
42
+
43
+
44
+ // Only intercept user-questions/request (AskUserQuestion).
45
+ // Do NOT intercept internal tool approval/request which causes false alarms
46
+ // when background agent tools run sandbox checks.
47
+ ctx.on('user-questions/request', (
48
+ request: AskUserQuestionRequest,
49
+ next: () => Promise<AskUserQuestionAnswer>,
50
+ ) => board.holdAsk(request, next), { prepend: true })
51
+ }
52
+
53
+ function debounce(fn: () => void, ms: number): (() => void) & { dispose: () => void } {
54
+ let timer: ReturnType<typeof setTimeout> | undefined
55
+ const notify = () => {
56
+ if (timer) clearTimeout(timer)
57
+ timer = setTimeout(() => { timer = undefined; fn() }, ms)
58
+ }
59
+ return Object.assign(notify, { dispose: () => { if (timer) clearTimeout(timer); timer = undefined } })
60
+ }
package/src/http.ts ADDED
@@ -0,0 +1,215 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import type { IncomingMessage, ServerResponse } from 'node:http'
3
+ import type { Context } from '@deepseek-ai/cordis'
4
+ import type { Board } from './board.ts'
5
+ import type { ApprovalOutcomeWire, NotchAnswerItem } from './types.ts'
6
+
7
+ const PREFIX = '/dsh-notch'
8
+
9
+ export function isLoopback(req: IncomingMessage): boolean {
10
+ const ip = req.socket.remoteAddress ?? ''
11
+ return ip === '127.0.0.1' || ip === '::1' || ip === ':ffff:127.0.0.1' || ip === '::ffff:127.0.0.1'
12
+ }
13
+
14
+ export function authorized(req: IncomingMessage, token: string): boolean {
15
+ const header = req.headers.authorization
16
+ if (header === `Bearer ${token}`) return true
17
+ try {
18
+ const url = new URL(req.url ?? '/', 'http://127.0.0.1')
19
+ return url.searchParams.get('token') === token
20
+ } catch {
21
+ return false
22
+ }
23
+ }
24
+
25
+ /**
26
+ * Browser-side trust for pages served by this same Host: loopback remote,
27
+ * never cross-site, and a matching Origin when one is present. Same rule the
28
+ * shipped workspace file panel uses — no secret ever lands in page JS.
29
+ */
30
+ export function browserTrusted(req: IncomingMessage): boolean {
31
+ if (!isLoopback(req)) return false
32
+ if (req.headers['sec-fetch-site'] === 'cross-site') return false
33
+ const host = req.headers.host
34
+ if (host === undefined) return false
35
+ const origin = req.headers.origin
36
+ if (origin === undefined) return true
37
+ try {
38
+ return new URL(origin).host === new URL(`http://${host}`).host
39
+ } catch {
40
+ return false
41
+ }
42
+ }
43
+
44
+ function send(res: ServerResponse, status: number, body: unknown): void {
45
+ const json = JSON.stringify(body)
46
+ res.writeHead(status, {
47
+ 'content-type': 'application/json; charset=utf-8',
48
+ 'cache-control': 'no-store',
49
+ })
50
+ res.end(json)
51
+ }
52
+
53
+ function readBody(req: IncomingMessage, limit = 64 * 1024): Promise<string> {
54
+ return new Promise((resolve, reject) => {
55
+ const chunks: Buffer[] = []
56
+ let size = 0
57
+ req.on('data', (chunk: Buffer) => {
58
+ size += chunk.length
59
+ if (size > limit) {
60
+ reject(new Error('body too large'))
61
+ req.destroy()
62
+ return
63
+ }
64
+ chunks.push(chunk)
65
+ })
66
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
67
+ req.on('error', reject)
68
+ })
69
+ }
70
+
71
+ export function attachHttp(ctx: Context, board: Board, token: string, origin: string): () => void {
72
+ const streams = new Map<string, ServerResponse>()
73
+ const push = () => {
74
+ const payload = `data: ${JSON.stringify(board.snapshot(origin))}\n\n`
75
+ for (const [id, res] of streams) {
76
+ try {
77
+ res.write(payload)
78
+ } catch {
79
+ streams.delete(id)
80
+ }
81
+ }
82
+ }
83
+ const stopListen = board.onChange(push)
84
+
85
+ const handler = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
86
+ const url = new URL(req.url ?? '/', 'http://127.0.0.1')
87
+ const path = url.pathname
88
+ const method = req.method ?? 'GET'
89
+
90
+ // Browser pages served by this same Host consume focus wishes through
91
+ // same-origin trust (no secret in page JS); everything else keeps the
92
+ // notch Bearer token.
93
+ if (method === 'GET' && path === `${PREFIX}/pending-focus`) {
94
+ if (!browserTrusted(req)) {
95
+ send(res, 403, { ok: false, error: 'forbidden' })
96
+ return
97
+ }
98
+ send(res, 200, { ok: true, focus: board.peekFocus() })
99
+ return
100
+ }
101
+
102
+ if (method === 'POST' && path === `${PREFIX}/sidebar`) {
103
+ // JSON, explicit same origin, and browser fetch metadata prevent cross-site writes.
104
+ if (!browserTrusted(req) || !req.headers.origin || !req.headers['content-type']?.startsWith('application/json')) {
105
+ send(res, 403, { ok: false, error: 'forbidden' })
106
+ return
107
+ }
108
+ const ok = board.syncSidebar(JSON.parse(await readBody(req, 512 * 1024)))
109
+ send(res, ok ? 200 : 400, { ok })
110
+ return
111
+ }
112
+
113
+ if (!isLoopback(req) || !authorized(req, token)) {
114
+ send(res, 403, { ok: false, error: 'forbidden' })
115
+ return
116
+ }
117
+
118
+ if (method === 'GET' && path === `${PREFIX}/status`) {
119
+ send(res, 200, board.snapshot(origin))
120
+ return
121
+ }
122
+
123
+ if (method === 'GET' && path === `${PREFIX}/events`) {
124
+ const id = randomUUID()
125
+ res.writeHead(200, {
126
+ 'content-type': 'text/event-stream',
127
+ 'cache-control': 'no-cache',
128
+ connection: 'keep-alive',
129
+ })
130
+ streams.set(id, res)
131
+ res.write(`data: ${JSON.stringify(board.snapshot(origin))}\n\n`)
132
+ req.on('close', () => { streams.delete(id) })
133
+ return
134
+ }
135
+
136
+ if (method === 'POST' && path === `${PREFIX}/seen`) {
137
+ const body = JSON.parse(await readBody(req)) as { sessionId?: string; all?: boolean }
138
+ if (body.all) {
139
+ board.markAllSeen()
140
+ send(res, 200, { ok: true })
141
+ return
142
+ }
143
+ if (!body.sessionId) {
144
+ send(res, 400, { ok: false, error: 'sessionId required' })
145
+ return
146
+ }
147
+ board.markSeen(body.sessionId)
148
+ send(res, 200, { ok: true })
149
+ return
150
+ }
151
+
152
+ if (method === 'POST' && path === `${PREFIX}/approve`) {
153
+ const body = JSON.parse(await readBody(req)) as { id?: string; outcome?: ApprovalOutcomeWire }
154
+ if (!body.id || (body.outcome !== 'allowed-once' && body.outcome !== 'rejected')) {
155
+ send(res, 400, { ok: false, error: 'id and outcome required' })
156
+ return
157
+ }
158
+ if (!board.decideApproval(body.id, body.outcome)) {
159
+ send(res, 404, { ok: false, error: 'not pending' })
160
+ return
161
+ }
162
+ send(res, 200, { ok: true })
163
+ return
164
+ }
165
+
166
+ if (method === 'POST' && path === `${PREFIX}/answer`) {
167
+ const body = JSON.parse(await readBody(req)) as { id?: string; answers?: NotchAnswerItem[] }
168
+ if (!body.id || !Array.isArray(body.answers)) {
169
+ send(res, 400, { ok: false, error: 'id and answers required' })
170
+ return
171
+ }
172
+ if (!board.answerAsk(body.id, body.answers)) {
173
+ send(res, 404, { ok: false, error: 'not pending' })
174
+ return
175
+ }
176
+ send(res, 200, { ok: true })
177
+ return
178
+ }
179
+
180
+ if (method === 'POST' && path === `${PREFIX}/focus`) {
181
+ const body = JSON.parse(await readBody(req)) as { sessionId?: string }
182
+ if (!body.sessionId) {
183
+ send(res, 400, { ok: false, error: 'sessionId required' })
184
+ return
185
+ }
186
+ if (!board.requestFocus(body.sessionId)) {
187
+ send(res, 404, { ok: false, error: 'unknown session' })
188
+ return
189
+ }
190
+ send(res, 200, { ok: true })
191
+ return
192
+ }
193
+
194
+ send(res, 404, { ok: false, error: 'not found' })
195
+ }
196
+
197
+ const disposeRoute = ctx.webServer.register({
198
+ kind: 'prefix',
199
+ path: PREFIX,
200
+ handler: (req, res) => {
201
+ void handler(req, res).catch((error: unknown) => {
202
+ if (!res.headersSent) send(res, 400, { ok: false, error: String(error) })
203
+ })
204
+ },
205
+ })
206
+
207
+ return () => {
208
+ stopListen()
209
+ for (const res of streams.values()) {
210
+ try { res.end() } catch { /* already closed */ }
211
+ }
212
+ streams.clear()
213
+ disposeRoute()
214
+ }
215
+ }
@@ -0,0 +1,31 @@
1
+ import type { Session } from '@deepseek-ai/dsh-session'
2
+ import type { NotchLastTurn } from './types.ts'
3
+
4
+ export interface FoldedSession {
5
+ busy: boolean
6
+ lastTurn?: NotchLastTurn
7
+ }
8
+
9
+ const FAILED = new Set(['error', 'blocked', 'max-tokens'])
10
+
11
+ export function foldSession(session: Session): FoldedSession {
12
+ let busy = false
13
+ let lastTurn: NotchLastTurn | undefined
14
+ for (const event of session.snapshotEvents()) {
15
+ if (event.type === 'turn/start') {
16
+ busy = true
17
+ lastTurn = undefined
18
+ continue
19
+ }
20
+ if (event.type === 'turn/end') {
21
+ busy = false
22
+ const kind = event.data.reason.kind
23
+ lastTurn = { at: event.time, kind, failed: FAILED.has(kind) }
24
+ }
25
+ }
26
+ return { busy, lastTurn }
27
+ }
28
+
29
+ export function isChildSession(session: Session): boolean {
30
+ return session.header.parentSession !== undefined || session.header.origin === 'subagent'
31
+ }