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.
- package/BASELINE.md +13 -0
- package/DESIGN.md +34 -0
- package/LICENSE +21 -0
- package/README.en.md +136 -0
- package/README.md +145 -0
- package/cordis.yml +5 -0
- package/docs/motion-audit.md +65 -0
- package/docs/motion-continuity.md +77 -0
- package/docs/releases/v0.3.0.md +20 -0
- package/dshx.yml +14 -0
- package/macos/Package.swift +17 -0
- package/macos/STATUS-MOTION.md +49 -0
- package/macos/Sources/Client.swift +139 -0
- package/macos/Sources/IdleRobot.swift +423 -0
- package/macos/Sources/NotchMarkdown.swift +266 -0
- package/macos/Sources/Panel.swift +153 -0
- package/macos/Sources/Resources/Idle/balance.json +1 -0
- package/macos/Sources/Resources/Idle/blink.json +1 -0
- package/macos/Sources/Resources/Idle/cube-in.json +1 -0
- package/macos/Sources/Resources/Idle/dance.json +1 -0
- package/macos/Sources/Resources/Idle/hop.json +1 -0
- package/macos/Sources/Resources/Idle/nod.json +1 -0
- package/macos/Sources/Resources/Idle/satellite-in.json +1 -0
- package/macos/Sources/Resources/Idle/satellite-out.json +1 -0
- package/macos/Sources/Resources/Idle/scan.json +1 -0
- package/macos/Sources/Resources/Idle/sleep.json +1 -0
- package/macos/Sources/Resources/Idle/sneeze.json +1 -0
- package/macos/Sources/Resources/Idle/stretch.json +1 -0
- package/macos/Sources/Resources/Idle/tilt.json +1 -0
- package/macos/Sources/ReviewStudio.swift +337 -0
- package/macos/Sources/RootView.swift +1093 -0
- package/macos/Sources/StatusOrbit.swift +642 -0
- package/macos/Sources/main.swift +153 -0
- package/macos/Tests/ExpandedHeightProbe.swift +108 -0
- package/macos/Tests/GeometryProbe.swift +98 -0
- package/macos/Tests/IdleProbe.swift +294 -0
- package/macos/Tests/MotionProbe.swift +106 -0
- package/macos/Tests/OutcomeProbe.swift +133 -0
- package/macos/Tests/ScrollbarProbe.swift +91 -0
- package/macos/Tests/expanded-height.sh +13 -0
- package/macos/Tests/geometry.sh +14 -0
- package/macos/Tests/idle.sh +14 -0
- package/macos/Tests/motion.sh +14 -0
- package/macos/Tests/outcome.sh +14 -0
- package/macos/Tests/scrollbar.sh +13 -0
- package/package.json +46 -0
- package/src/board.ts +299 -0
- package/src/browse-sync.ts +59 -0
- package/src/dsh-notch.ts +60 -0
- package/src/http.ts +215 -0
- package/src/session-state.ts +31 -0
- package/src/store.ts +49 -0
- package/src/types.ts +65 -0
- package/tests/answer-lifecycle.test.mjs +23 -0
- package/tests/board.test.mjs +7 -0
- package/tests/browse-sync.test.mjs +20 -0
- package/tests/mirror.test.mjs +4 -0
- package/tests/peer-range.test.mjs +21 -0
- package/tests/session-state.test.mjs +54 -0
- package/tests/unread-hold.test.mjs +90 -0
- package/tools/desktop-shell/README.md +56 -0
- package/tools/desktop-shell/child-log.mjs +6 -0
- package/tools/desktop-shell/child-log.test.mjs +14 -0
- package/tools/desktop-shell/package-recovery.py +78 -0
- package/tools/desktop-shell/recovery-probe.cjs +50 -0
- package/tools/desktop-shell/renderer-recovery.mjs +95 -0
- package/tools/desktop-shell/renderer-recovery.test.mjs +24 -0
- package/tools/diagnostics/completion-watch.py +101 -0
- package/tools/diagnostics/completion-watch.test.py +17 -0
- package/tools/diagnostics/renderer-diagnostics.mjs +56 -0
- package/tools/diagnostics/renderer-diagnostics.test.mjs +32 -0
- package/tools/idle/LICENSE.open-bot-motion +21 -0
- package/tools/idle/export.mjs +40 -0
- package/tools/idle/index.html +4 -0
- package/tools/idle/motions.js +83 -0
- package/tools/idle/original.js +1673 -0
- package/tools/idle/renderer.js +1673 -0
- package/tools/recording/Client.swift +76 -0
- package/tools/recording/DemoCatalog.swift +174 -0
- package/tools/recording/README.md +23 -0
- package/tools/recording/build.sh +41 -0
- package/tools/recording/main.swift +100 -0
- package/tools/review/ReviewStudio.swift +236 -0
- package/tools/review/audit.sh +19 -0
- package/tools/review/build.sh +7 -0
- package/tools/review/main.swift +158 -0
- 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
|
+
}
|
package/src/dsh-notch.ts
ADDED
|
@@ -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
|
+
}
|