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/store.ts ADDED
@@ -0,0 +1,49 @@
1
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
2
+ import { homedir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { randomBytes } from 'node:crypto'
5
+
6
+ const DIR = join(homedir(), '.dsh', 'dsh-notch')
7
+ const RUNTIME = join(DIR, 'runtime.json')
8
+ const SEEN = join(DIR, 'seen.json')
9
+
10
+ export interface RuntimeFile {
11
+ origin: string
12
+ token: string
13
+ pid: number
14
+ writtenAt: number
15
+ }
16
+
17
+ function ensureDir(): void {
18
+ mkdirSync(DIR, { recursive: true, mode: 0o700 })
19
+ }
20
+
21
+ function readJson<T>(path: string): T | undefined {
22
+ try {
23
+ return JSON.parse(readFileSync(path, 'utf8')) as T
24
+ } catch {
25
+ return undefined
26
+ }
27
+ }
28
+
29
+ export function loadOrCreateToken(): string {
30
+ const existing = readJson<RuntimeFile>(RUNTIME)
31
+ if (existing?.token && existing.token.length >= 16) return existing.token
32
+ return randomBytes(24).toString('hex')
33
+ }
34
+
35
+ export function writeRuntime(file: RuntimeFile): void {
36
+ ensureDir()
37
+ writeFileSync(RUNTIME, `${JSON.stringify(file, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 })
38
+ }
39
+
40
+ export function loadSeen(): Record<string, number> {
41
+ const value = readJson<Record<string, number>>(SEEN)
42
+ if (!value || typeof value !== 'object') return {}
43
+ return value
44
+ }
45
+
46
+ export function saveSeen(map: Record<string, number>): void {
47
+ ensureDir()
48
+ writeFileSync(SEEN, `${JSON.stringify(map)}\n`, { encoding: 'utf8', mode: 0o600 })
49
+ }
package/src/types.ts ADDED
@@ -0,0 +1,65 @@
1
+ /** JSON contract between the Host plugin and the native helper. */
2
+
3
+ export type ApprovalOutcomeWire = 'allowed-once' | 'rejected'
4
+
5
+ export interface NotchOption {
6
+ label: string
7
+ description?: string
8
+ }
9
+
10
+ export interface NotchQuestion {
11
+ id: string
12
+ question: string
13
+ detail?: string
14
+ header?: string
15
+ options?: NotchOption[]
16
+ multiSelect?: boolean
17
+ intent?: { kind: 'plan-review'; approve: string }
18
+ }
19
+
20
+ export interface NotchApproval {
21
+ id: string
22
+ toolName: string
23
+ reason?: string
24
+ }
25
+
26
+ export interface NotchAsk {
27
+ id: string
28
+ questions: NotchQuestion[]
29
+ }
30
+
31
+ export interface NotchLastTurn {
32
+ at: number
33
+ kind: string
34
+ failed: boolean
35
+ }
36
+
37
+ export interface NotchRow {
38
+ id: string
39
+ title: string
40
+ child: boolean
41
+ busy: boolean
42
+ unread: boolean
43
+ lastTurn?: NotchLastTurn
44
+ approval?: NotchApproval
45
+ ask?: NotchAsk
46
+ }
47
+
48
+ export interface NotchSnapshot {
49
+ ok: true
50
+ generatedAt: number
51
+ sidebarSyncedAt?: number
52
+ origin: string
53
+ rows: NotchRow[]
54
+ }
55
+
56
+ export interface NotchAnswerItem {
57
+ id: string
58
+ selected: string[]
59
+ custom?: string
60
+ }
61
+
62
+ export interface NotchFocus {
63
+ sessionId: string
64
+ at: number
65
+ }
@@ -0,0 +1,23 @@
1
+ import {test} from 'node:test';import assert from 'node:assert/strict';
2
+ import os from 'node:os';
3
+ import {mkdtempSync, rmSync} from 'node:fs';
4
+ import {syncBuiltinESMExports} from 'node:module';
5
+ import {after} from 'node:test';
6
+ const testDir=mkdtempSync(os.tmpdir()+'/notch-answer-test-');
7
+ const realHomedir=os.homedir;
8
+ os.homedir=()=>testDir; syncBuiltinESMExports();
9
+ const {Board}=await import('../src/board.ts');
10
+ os.homedir=realHomedir; syncBuiltinESMExports();
11
+ after(()=>rmSync(testDir,{recursive:true,force:true}));
12
+ const session={id:'notch-lifecycle-test',header:{},snapshotEvents:()=>[]};
13
+ const ctx={sessions:{list:()=>[session]},agents:{get:()=>({status:'running'})},get:()=>undefined,logger:{warn(){}}};
14
+ test('Notch answer cancels the web wait and preserves caller signal',async()=>{
15
+ const board=new Board(ctx), owner=new AbortController();const request={agent:{id:session.id},questions:[{id:'q',question:'test'}],signal:owner.signal};let webVisible=true;
16
+ const pending=board.holdAsk(request,()=>new Promise((resolve,reject)=>{request.signal.addEventListener('abort',()=>{webVisible=false;reject(Error('web wait ended'))},{once:true})}));
17
+ const ask=board.snapshot('http://localhost').rows[0].ask;assert.ok(ask);assert.equal(board.answerAsk(ask.id,[{id:'q',selected:['test']}]),true);
18
+ assert.deepEqual(await pending,{answers:[{id:'q',selected:['test']}]});assert.equal(webVisible,false);assert.equal(owner.signal.aborted,false);assert.equal(request.signal,owner.signal);
19
+ });
20
+ test('Web answer removes the Notch request',async()=>{
21
+ const board=new Board(ctx);let finish;const answer={answers:[{id:'q',selected:[]}]};
22
+ const pending=board.holdAsk({agent:{id:session.id},questions:[{id:'q',question:'test'}]},()=>new Promise(r=>finish=r));await Promise.resolve();finish(answer);assert.equal(await pending,answer);assert.equal(board.snapshot('http://localhost').rows[0].ask,undefined);
23
+ });
@@ -0,0 +1,7 @@
1
+ import {test} from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import {Board} from '../src/board.ts'
4
+ const session={id:'session-regression',header:{},snapshotEvents:()=>[]}
5
+ const ctx={sessions:{list:()=>[session]},agents:{get:()=>({status:'running'})},get:()=>undefined,logger:{warn:()=>{}}}
6
+ test('snapshot and background notification handle live sessions without missing seen state',()=>{const board=new Board(ctx);let notifications=0;board.onChange(()=>{assert.equal(board.snapshot('http://localhost').rows.length,1);notifications++});board.notify();assert.equal(notifications,1)})
7
+ test('one broken subscriber cannot crash Host or stop other subscribers',()=>{const board=new Board(ctx);let n=0;board.onChange(()=>{throw Error('broken stream')});board.onChange(()=>n++);assert.doesNotThrow(()=>board.notify());assert.equal(n,1)})
@@ -0,0 +1,20 @@
1
+ import {test} from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import {installBrowseSync} from '../src/browse-sync.ts'
4
+ class Controller { history={page:async()=>({records:[1]}),follow:async function*(){yield {records:[1]}}}; page(request,signal){return this.history.page(request,signal)} follow(request,signal){return this.history.follow(request,signal)} }
5
+ const req={address:{sessionId:'test'}}
6
+ test('legacy synchronous Notch wrapper reproduces exact error; repair removes all nested wrappers',async()=>{
7
+ const c=new Controller();const board={markSeen(){}};const getTargetSessionId=a=>a.sessionId
8
+ for(let i=0;i<2;i++){const origFollow=c.follow.bind(c);c.follow=function* (req, signal) {const sid=getTargetSessionId(req.address);if(sid)board.markSeen(sid);return yield* origFollow(req,signal)}}
9
+ await assert.rejects(async()=>{for await(const x of c.follow(req)){}},/is not iterable/)
10
+ const stop=installBrowseSync(c,()=>{});const rows=[];for await(const x of c.follow(req))rows.push(x)
11
+ assert.deepEqual(rows,[{records:[1]}]);stop();assert.equal(Object.hasOwn(c,'follow'),false)
12
+ })
13
+ test('preserves async stream identity, return, cancellation; repeated generations dispose cleanly',async()=>{
14
+ const c=new Controller();let returned=0;const signal=AbortSignal.abort();const source={async *[Symbol.asyncIterator](){try{yield 1;yield 2}finally{returned++}}}
15
+ c.history.follow=(r,s)=>{assert.equal(s,signal);return source}
16
+ let a=0,b=0;const stopA=installBrowseSync(c,()=>a++);const stopB=installBrowseSync(c,()=>b++)
17
+ assert.equal(c.follow(req,signal),source);stopA();const stream=c.follow(req,signal);for await(const v of stream){break}
18
+ assert.equal(a,1);assert.equal(b,2);assert.equal(returned,1);stopB();stopB();assert.equal(Object.hasOwn(c,'follow'),false)
19
+ const stop=installBrowseSync(c,()=>{throw Error('read marker failed')});assert.deepEqual(await c.page(req),{records:[1]});stop()
20
+ })
@@ -0,0 +1,4 @@
1
+ import {test} from 'node:test';import assert from 'node:assert/strict';import {Board} from '../src/board.ts';
2
+ const ctx={sessions:{list:()=>[]},agents:{get:()=>undefined},get:()=>undefined,logger:{warn:()=>{}}};
3
+ test('sidebar completion survives cold session; opening in DSH clears green; background page cannot overwrite',()=>{const b=new Board(ctx);const row={id:'session-cold',title:'Cold',running:false,completed:true};assert.equal(b.syncSidebar({clientId:'app',focused:true,rows:[row]}),true);assert.equal(b.snapshot('').rows[0].unread,true);assert.equal(b.requestFocus(row.id),true);b.syncSidebar({clientId:'app',focused:true,rows:[]});assert.equal(b.snapshot('').rows.length,0);b.syncSidebar({clientId:'browser',focused:false,rows:[row]});assert.equal(b.snapshot('').rows.length,0)});
4
+ test('malformed mirror never replaces previous valid state',()=>{const b=new Board(ctx);assert.equal(b.syncSidebar({clientId:'app',rows:[{id:123}]}),false);assert.deepEqual(b.snapshot('').rows,[])});
@@ -0,0 +1,21 @@
1
+ import { test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { readFileSync } from 'node:fs'
4
+
5
+ const RANGE = '>=0.1.7-rc.1 <0.1.8'
6
+ const PEERS = [
7
+ '@deepseek-ai/dsh',
8
+ '@deepseek-ai/dsh-host-webserver',
9
+ '@deepseek-ai/dsh-session',
10
+ '@deepseek-ai/dsh-user-approval',
11
+ '@deepseek-ai/dsh-user-questions',
12
+ ]
13
+
14
+ test('Harness peer range is the 0.1.7-rc.1 desk pin', () => {
15
+ const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
16
+ assert.equal(pkg.version, '0.3.1')
17
+ for (const name of PEERS) {
18
+ assert.equal(pkg.peerDependencies[name], RANGE)
19
+ assert.equal(pkg.peerDependenciesMeta[name].optional, true)
20
+ }
21
+ })
@@ -0,0 +1,54 @@
1
+ import { test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { foldSession } from '../src/session-state.ts'
4
+
5
+ function session(events) {
6
+ return { snapshotEvents: () => events }
7
+ }
8
+
9
+ test('aborted last turn is not a failure lamp', () => {
10
+ const folded = foldSession(session([
11
+ { type: 'turn/start', time: 1 },
12
+ { type: 'turn/end', time: 2, data: { reason: { kind: 'aborted' } } },
13
+ ]))
14
+ assert.equal(folded.busy, false)
15
+ assert.equal(folded.lastTurn.kind, 'aborted')
16
+ assert.equal(folded.lastTurn.failed, false)
17
+ })
18
+
19
+ test('interrupted last turn is not a failure lamp', () => {
20
+ const folded = foldSession(session([
21
+ { type: 'turn/start', time: 1 },
22
+ { type: 'turn/end', time: 2, data: { reason: { kind: 'interrupted' } } },
23
+ ]))
24
+ assert.equal(folded.lastTurn.failed, false)
25
+ })
26
+
27
+ test('a new turn clears the previous abort so a running session cannot stay red', () => {
28
+ const folded = foldSession(session([
29
+ { type: 'turn/start', time: 1 },
30
+ { type: 'turn/end', time: 2, data: { reason: { kind: 'aborted' } } },
31
+ { type: 'turn/start', time: 3 },
32
+ ]))
33
+ assert.equal(folded.busy, true)
34
+ assert.equal(folded.lastTurn, undefined)
35
+ })
36
+
37
+ test('a fork-seed closer is not a failure lamp', () => {
38
+ const folded = foldSession(session([
39
+ { type: 'turn/start', time: 1 },
40
+ { type: 'turn/end', time: 2, data: { reason: { kind: 'forked' } } },
41
+ ]))
42
+ assert.equal(folded.busy, false)
43
+ assert.equal(folded.lastTurn.kind, 'forked')
44
+ assert.equal(folded.lastTurn.failed, false)
45
+ })
46
+
47
+ test('error still counts as failed after the turn ends', () => {
48
+ const folded = foldSession(session([
49
+ { type: 'turn/start', time: 1 },
50
+ { type: 'turn/end', time: 2, data: { reason: { kind: 'error' } } },
51
+ ]))
52
+ assert.equal(folded.busy, false)
53
+ assert.equal(folded.lastTurn.failed, true)
54
+ })
@@ -0,0 +1,90 @@
1
+ import { test, after } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import os from 'node:os'
4
+ import { mkdtempSync, rmSync } from 'node:fs'
5
+ import { syncBuiltinESMExports } from 'node:module'
6
+
7
+ const testDir = mkdtempSync(os.tmpdir() + '/notch-unread-')
8
+ const realHomedir = os.homedir
9
+ os.homedir = () => testDir
10
+ syncBuiltinESMExports()
11
+ const { Board } = await import('../src/board.ts')
12
+ os.homedir = realHomedir
13
+ syncBuiltinESMExports()
14
+ after(() => rmSync(testDir, { recursive: true, force: true }))
15
+
16
+ test('a single running task becomes a green unread lamp instead of vanishing', () => {
17
+ let running = true
18
+ const events = [{ type: 'turn/start', time: 1 }]
19
+ const session = {
20
+ id: 'session-one',
21
+ header: {},
22
+ snapshotEvents: () => events,
23
+ }
24
+ const ctx = {
25
+ sessions: { list: () => [session] },
26
+ agents: { get: () => ({ status: running ? 'running' : 'idle' }) },
27
+ get: () => undefined,
28
+ logger: { warn() {} },
29
+ }
30
+ const board = new Board(ctx)
31
+ assert.equal(board.snapshot('').rows[0].busy, true)
32
+
33
+ running = false
34
+ events.push({ type: 'turn/end', time: 2, data: { reason: { kind: 'completed' } } })
35
+ const done = board.snapshot('').rows[0]
36
+ assert.equal(done.busy, false)
37
+ assert.equal(done.unread, true)
38
+ assert.equal(board.snapshot('').rows[0].unread, true)
39
+
40
+ board.markSeen(session.id)
41
+ assert.equal(board.snapshot('').rows.length, 0)
42
+ })
43
+
44
+ test('clicking seen clears green even if the sidebar still says completed', () => {
45
+ let running = true
46
+ const events = [{ type: 'turn/start', time: 1 }]
47
+ const session = {
48
+ id: 'session-sidebar',
49
+ header: {},
50
+ snapshotEvents: () => events,
51
+ }
52
+ const ctx = {
53
+ sessions: { list: () => [session] },
54
+ agents: { get: () => ({ status: running ? 'running' : 'idle' }) },
55
+ get: () => undefined,
56
+ logger: { warn() {} },
57
+ }
58
+ const board = new Board(ctx)
59
+ board.snapshot('')
60
+ running = false
61
+ events.push({ type: 'turn/end', time: 2, data: { reason: { kind: 'completed' } } })
62
+ board.snapshot('')
63
+ board.syncSidebar({
64
+ clientId: 'app',
65
+ focused: true,
66
+ rows: [{ id: session.id, title: 'Done', running: false, completed: true }],
67
+ })
68
+ assert.equal(board.snapshot('').rows[0].unread, true)
69
+ board.markSeen(session.id)
70
+ assert.equal(board.snapshot('').rows.length, 0)
71
+ })
72
+
73
+ test('cold completed history is not replayed as green', () => {
74
+ const session = {
75
+ id: 'session-old',
76
+ header: {},
77
+ snapshotEvents: () => [
78
+ { type: 'turn/start', time: 1 },
79
+ { type: 'turn/end', time: 2, data: { reason: { kind: 'completed' } } },
80
+ ],
81
+ }
82
+ const ctx = {
83
+ sessions: { list: () => [session] },
84
+ agents: { get: () => ({ status: 'idle' }) },
85
+ get: () => undefined,
86
+ logger: { warn() {} },
87
+ }
88
+ const board = new Board(ctx)
89
+ assert.equal(board.snapshot('').rows.length, 0)
90
+ })
@@ -0,0 +1,56 @@
1
+ # Desktop renderer recovery
2
+
3
+ This optional thin-shell patch prevents an unexpected renderer exit from leaving
4
+ an existing DSH window permanently blank. It does **not** identify or fix the
5
+ underlying long-idle renderer termination.
6
+
7
+ The recovery controller listens to `render-process-gone`, including `clean-exit`.
8
+ While the app/window remains open it reloads the original WebContents, preserves
9
+ its navigation/auth context, requests a repaint after load, and records the result.
10
+ It never starts, stops or restarts a Host. Two automatic attempts are allowed per
11
+ minute; further exits and a 15-second load timeout go to a native error prompt.
12
+ App quit/window close cancels pending work. The shell integration adds window and
13
+ system idle metadata to bounded local diagnostic logs.
14
+
15
+ `child-log.mjs` fixes a separate shutdown race: each child stdout/stderr stream
16
+ pipes with `end: false` so EOF cannot close the shared desktop log before an exit
17
+ callback writes to it. Logging errors cannot raise an uncaught main-process error.
18
+
19
+ ## Checks
20
+
21
+ ```sh
22
+ node --test tools/desktop-shell/*.test.mjs tools/diagnostics/renderer-diagnostics.test.mjs
23
+ "$DSH_TEST_ELECTRON" tools/desktop-shell/recovery-probe.cjs --baseline
24
+ "$DSH_TEST_ELECTRON" tools/desktop-shell/recovery-probe.cjs
25
+ ```
26
+
27
+ Use the target app's matching Electron executable. The baseline is expected to
28
+ fail: it has no recovery handler. The repaired native integration fixture uses a
29
+ temporary profile/static page, crashes only its own renderer, checks both visible
30
+ and hidden windows twice, verifies an actual painted frame against its healthy
31
+ baseline (including display color management), and verifies retry limiting.
32
+ It never reads DSH conversations or invokes models. This is an Electron lifecycle
33
+ test, not an alternative browser automation interface.
34
+
35
+ ## Package and activate
36
+
37
+ `package-recovery.py <original-app.asar> <staging-directory>` produces a patched
38
+ ASAR, readable main module, and input/output hash manifest. It requires the known
39
+ thin-shell source shape with the earlier diagnostics hook and refuses an already
40
+ patched or unfamiliar archive. Every unrelated archive entry is preserved and
41
+ all output entry bytes are verified. To test packaged recovery, set
42
+ `DSH_RECOVERY_MODULE` to the staged ASAR's `src/renderer-recovery.mjs` before the
43
+ native probe. The script never installs or controls a process.
44
+
45
+ Keep a verified copy of the original ASAR. Installation is a separate, authorized,
46
+ atomic resource replacement after checking that the current archive still matches
47
+ the reviewed input. Shell-main changes require a controlled App reopen. The current
48
+ App owns its Host: do this only with the applicable task authorization and an idle
49
+ runtime, and prove that the old Host exited before launching the original App.
50
+ Once active, routine renderer recovery does not require another App/Host restart.
51
+ Verify diagnostic attachment plus the actual conversation/composer through the
52
+ normal app. A package checksum or a recovery log is not visual acceptance.
53
+
54
+ For rollback, restore the exact preserved original archive and perform the same
55
+ controlled App reopen. Never replace the user's checkout, history or Notch assets.
56
+ Do not commit local logs, screenshots, session data or installed-artifact backups.
@@ -0,0 +1,6 @@
1
+ // stdout and stderr (and sometimes several children) share one desktop log.
2
+ // EOF belongs to an input stream; it must not end the shared destination.
3
+ export function pipeChildLog(child, log) {
4
+ child.stdout?.pipe(log, {end: false});
5
+ child.stderr?.pipe(log, {end: false});
6
+ }
@@ -0,0 +1,14 @@
1
+ import {test} from 'node:test';import assert from 'node:assert/strict';import {PassThrough} from 'node:stream';
2
+ import {pipeChildLog} from './child-log.mjs';
3
+ test('child EOF leaves shared log open for exit callback and other child',async()=>{
4
+ const log=new PassThrough(),chunks=[],errors=[];log.on('data',c=>chunks.push(c.toString()));log.on('error',e=>errors.push(e));
5
+ const first={stdout:new PassThrough(),stderr:new PassThrough()},second={stdout:new PassThrough(),stderr:new PassThrough()};
6
+ pipeChildLog(first,log);pipeChildLog(second,log);
7
+ first.stdout.end('first-out\n');first.stderr.end('first-err\n');
8
+ await new Promise(setImmediate);
9
+ assert.equal(log.writableEnded,false);
10
+ log.write('first-exit\n');second.stdout.end('second-out\n');second.stderr.end('second-err\n');
11
+ await new Promise(setImmediate);
12
+ log.write('second-exit\n');await new Promise(setImmediate);
13
+ assert.equal(errors.length,0);assert.match(chunks.join(''),/first-exit/);assert.match(chunks.join(''),/second-exit/);log.end();
14
+ });
@@ -0,0 +1,78 @@
1
+ #!/usr/bin/env python3
2
+ """Stage a reviewed thin-shell patch. Never installs or restarts DSH."""
3
+ from pathlib import Path
4
+ import argparse, hashlib, json, struct
5
+ p=argparse.ArgumentParser();p.add_argument('archive',type=Path);p.add_argument('output',type=Path);args=p.parse_args()
6
+ raw=args.archive.read_bytes();hs=struct.unpack_from('<I',raw,4)[0];js=struct.unpack_from('<I',raw,12)[0]
7
+ tree=json.loads(raw[16:16+js]);files={}
8
+ def walk(entries,prefix=''):
9
+ for name,e in entries.items():
10
+ key=prefix+name
11
+ if 'files' in e:walk(e['files'],key+'/')
12
+ else:
13
+ assert not e.get('unpacked') and 'link' not in e, 'unsupported archive entry'
14
+ start=8+hs+int(e['offset']);files[key]=raw[start:start+e['size']]
15
+ walk(tree['files'])
16
+ original=dict(files);source=files['src/main.mjs'].decode()
17
+ assert 'installRendererRecovery' not in source, 'recovery already installed; use the preserved base archive'
18
+ def replace(old,new):
19
+ global source
20
+ assert source.count(old)==1, 'unexpected shell source: '+old[:70]
21
+ source=source.replace(old,new,1)
22
+ replace("import { createDiagnosticLog, installRendererDiagnostics }", "import { pipeChildLog } from './child-log.mjs'\nimport { installRendererRecovery } from './renderer-recovery.mjs'\nimport { createDiagnosticLog, installRendererDiagnostics }")
23
+ replace('const { app, BrowserWindow, Menu, clipboard, session, shell, systemPreferences } = electron','const { app, BrowserWindow, Menu, clipboard, session, shell, systemPreferences, powerMonitor, dialog } = electron')
24
+ replace(''' const attachDiagnostics = contents => installRendererDiagnostics({
25
+ app, contents, write: diagnosticWrite,
26
+ related: () => ({hostPid: sidecar?.pid ?? null, notchPid: notch?.pid ?? null}),
27
+ })''',''' const attachDiagnostics = contents => {
28
+ const owner = BrowserWindow.fromWebContents(contents)
29
+ installRendererDiagnostics({
30
+ app, contents, window: owner, powerMonitor, write: diagnosticWrite,
31
+ related: () => ({hostPid: sidecar?.pid ?? null, notchPid: notch?.pid ?? null}),
32
+ })
33
+ let promptOpen = false
34
+ installRendererRecovery({
35
+ app, window: owner, write: diagnosticWrite,
36
+ onUnavailable: async () => {
37
+ if (promptOpen || owner.isDestroyed()) return
38
+ promptOpen = true
39
+ try {
40
+ const result = await dialog.showMessageBox(owner, {
41
+ type: 'error', title: 'DSH 页面未能恢复',
42
+ message: '页面连续异常退出或重新加载超时。',
43
+ detail: '后台服务没有因页面恢复而重启。可以重新加载页面,或稍后从 View → Reload 重试。',
44
+ buttons: ['重新加载页面', '稍后'], defaultId: 0, cancelId: 1, noLink: true,
45
+ })
46
+ if (result.response === 0 && !owner.isDestroyed()) contents.reload()
47
+ } finally { promptOpen = false }
48
+ },
49
+ })
50
+ }''')
51
+ pair=" child.stdout?.pipe(log)\n child.stderr?.pipe(log)"
52
+ assert source.count(pair)==2
53
+ source=source.replace(pair, ' pipeChildLog(child, log)')
54
+ replace(" return log\n}", " log.on('error', () => {}) // Logging failure must not crash the desktop shell.\n return log\n}")
55
+ root=Path(__file__).parent
56
+ files['src/child-log.mjs']=(root/'child-log.mjs').read_bytes()
57
+ files['src/main.mjs']=source.encode()
58
+ files['src/renderer-recovery.mjs']=(root/'renderer-recovery.mjs').read_bytes()
59
+ files['src/renderer-diagnostics.mjs']=(root.parent/'diagnostics/renderer-diagnostics.mjs').read_bytes()
60
+ header={'files':{}};payload=bytearray()
61
+ for name,body in files.items():
62
+ node=header['files'];parts=name.split('/')
63
+ for part in parts[:-1]:node=node.setdefault(part,{'files':{}})['files']
64
+ digest=hashlib.sha256(body).hexdigest()
65
+ node[parts[-1]]={'size':len(body),'offset':str(len(payload)),'integrity':{'algorithm':'SHA256','hash':digest,'blockSize':4194304,'blocks':[hashlib.sha256(body[i:i+4194304]).hexdigest() for i in range(0,len(body),4194304)]}}
66
+ payload.extend(body)
67
+ encoded=json.dumps(header,separators=(',',':')).encode();body=struct.pack('<I',len(encoded))+encoded;body+=b'\0'*((-len(body))%4);pickle=struct.pack('<I',len(body))+body
68
+ archive=struct.pack('<II',4,len(pickle))+pickle+payload
69
+ args.output.mkdir(parents=True,exist_ok=True)
70
+ (args.output/'app.asar').write_bytes(archive)
71
+ (args.output/'main.mjs').write_text(source)
72
+ new_hs=struct.unpack_from('<I',archive,4)[0]
73
+ for name,expected in files.items():
74
+ node=header['files']
75
+ for part in name.split('/')[:-1]:node=node[part]['files']
76
+ e=node[name.split('/')[-1]];start=8+new_hs+int(e['offset']);assert archive[start:start+e['size']]==expected
77
+ manifest={'inputSHA256':hashlib.sha256(raw).hexdigest(),'outputSHA256':hashlib.sha256(archive).hexdigest(),'changed':[n for n,b in files.items() if original.get(n)!=b]}
78
+ (args.output/'manifest.json').write_text(json.dumps(manifest,indent=2)+'\n');print(json.dumps(manifest))
@@ -0,0 +1,50 @@
1
+ // Native integration test: isolated profile/static fixture, never the user's DSH.
2
+ const {app,BrowserWindow}=require('electron');
3
+ const assert=require('node:assert/strict');
4
+ const fs=require('node:fs'),os=require('node:os'),path=require('node:path');
5
+ const {pathToFileURL}=require('node:url');
6
+ const profile=fs.mkdtempSync(path.join(os.tmpdir(),'dsh-recovery-test-'));
7
+ app.setPath('userData',profile);app.setPath('sessionData',profile);
8
+ app.on('window-all-closed',()=>{});
9
+ const nativePid=process.pid;
10
+ const waitFor=(emitter,event)=>new Promise(resolve=>emitter.once(event,resolve));
11
+ const delay=ms=>new Promise(resolve=>setTimeout(resolve,ms));
12
+ const deadline=setTimeout(()=>{console.error('native recovery timeout');app.exit(2)},20000);
13
+ app.whenReady().then(async()=>{
14
+ const {installRendererRecovery}=await import(pathToFileURL(process.env.DSH_RECOVERY_MODULE||path.join(__dirname,'renderer-recovery.mjs')));
15
+ const results=[];
16
+ for(const hidden of [false,true]){
17
+ const events=[];let unavailable=0;
18
+ const window=new BrowserWindow({width:460,height:220,show:false,webPreferences:{sandbox:true,contextIsolation:true,nodeIntegration:false}});
19
+ const wc=window.webContents;
20
+ let complete;
21
+ if(!process.argv.includes('--baseline'))installRendererRecovery({app,window,write:(event,data)=>{events.push({event,...data});if(event==='renderer-recovery-complete')complete?.()},onUnavailable:()=>unavailable++});
22
+ await window.loadURL('data:text/html,<title>DSH recovery fixture</title><body style="margin:0;background:%23004c2e;color:white;font:24px sans-serif;padding:32px"><h1>Recovery fixture</h1><p>Local static page. No model calls.</p></body>');
23
+ if(!hidden)window.showInactive();
24
+ const originalContents=wc.id;
25
+ const baselinePixels=(await wc.capturePage()).toBitmap();
26
+ const reference=Array.from(baselinePixels.subarray(0,4));
27
+ assert.ok(reference[1]>reference[0]&&reference[1]>reference[2]&&reference[3]>0,'baseline fixture must be green');
28
+ for(let cycle=1;cycle<=2;cycle++){
29
+ const oldPid=wc.getOSProcessId();
30
+ const recovered=new Promise(resolve=>{complete=resolve});
31
+ const gone=waitFor(wc,'render-process-gone');
32
+ wc.forcefullyCrashRenderer();
33
+ await gone;
34
+ if(process.argv.includes('--baseline')){await delay(700);assert.ok(wc.getOSProcessId()>0,'renderer remains dead: persistent blank window after exit');}
35
+ await recovered;
36
+ assert.equal(process.pid,nativePid);assert.equal(wc.id,originalContents);
37
+ assert.ok(wc.getOSProcessId()>0);assert.notEqual(wc.getOSProcessId(),oldPid);
38
+ assert.equal(await wc.executeJavaScript('document.querySelector("h1").textContent'),'Recovery fixture');
39
+ const frame=await wc.capturePage();const pixels=frame.toBitmap();
40
+ let green=0;for(let i=0;i<pixels.length;i+=4)if(reference.every((v,j)=>Math.abs(pixels[i+j]-v)<=3))green++;
41
+ assert.ok(green>pixels.length/4*.5,'recovered page must paint fixture background instead of white');
42
+ results.push({hidden,cycle,recovered:true,sameContents:true,painted:true});
43
+ }
44
+ const gone=waitFor(wc,'render-process-gone');wc.forcefullyCrashRenderer();await gone;await delay(400);
45
+ assert.equal(unavailable,1,'third rapid exit must stop auto reload');
46
+ window.destroy();
47
+ }
48
+ console.log(JSON.stringify({electron:process.versions.electron,results,boundedRetry:true}));
49
+ clearTimeout(deadline);app.exit(0);
50
+ }).catch(error=>{console.error(error.stack);clearTimeout(deadline);app.exit(1)});
@@ -0,0 +1,95 @@
1
+ // Recovery belongs to the desktop window, never to the Host or a plugin session.
2
+ // A zero exit code is still unexpected while the user's window remains open.
3
+ export function installRendererRecovery({
4
+ app, window, write = () => {}, onUnavailable = () => {},
5
+ repaint = () => window.webContents.invalidate(),
6
+ now = Date.now, scheduler = {setTimeout, clearTimeout},
7
+ retryDelayMs = 250, loadTimeoutMs = 15000, maxAttempts = 2, retryWindowMs = 60000,
8
+ }) {
9
+ const contents = window.webContents;
10
+ const listeners = [];
11
+ let disposed = false, quitting = false, closing = false;
12
+ let pending = false, reloadTimer = null, deadlineTimer = null, attempts = [];
13
+ const on = (emitter, event, fn) => {
14
+ emitter.on(event, fn);
15
+ listeners.push(() => emitter.removeListener(event, fn));
16
+ };
17
+ const live = () => !disposed && !quitting && !closing && !window.isDestroyed() && !contents.isDestroyed();
18
+ const record = (event, data = {}) => {
19
+ try { write(event, {contentsId: contents.id, ...data}); } catch {}
20
+ };
21
+ const clearTimers = () => {
22
+ if (reloadTimer !== null) scheduler.clearTimeout(reloadTimer);
23
+ if (deadlineTimer !== null) scheduler.clearTimeout(deadlineTimer);
24
+ reloadTimer = deadlineTimer = null;
25
+ };
26
+ const unavailable = reason => {
27
+ record('renderer-recovery-unavailable', {reason});
28
+ try { Promise.resolve(onUnavailable(reason)).catch(() => {}); } catch {}
29
+ };
30
+ const watchLoad = () => {
31
+ if (deadlineTimer !== null) scheduler.clearTimeout(deadlineTimer);
32
+ deadlineTimer = scheduler.setTimeout(() => {
33
+ deadlineTimer = null;
34
+ if (live() && pending) unavailable('load-timeout');
35
+ }, loadTimeoutMs);
36
+ };
37
+ on(contents, 'render-process-gone', (_event, details) => {
38
+ if (!live()) return;
39
+ if (reloadTimer !== null) return;
40
+ clearTimers();
41
+ pending = false;
42
+ const time = now();
43
+ attempts = attempts.filter(at => time - at < retryWindowMs);
44
+ if (attempts.length >= maxAttempts) { unavailable('repeated-exit'); return; }
45
+ attempts.push(time);
46
+ pending = true;
47
+ record('renderer-recovery-scheduled', {reason: details.reason, exitCode: details.exitCode, attempt: attempts.length});
48
+ reloadTimer = scheduler.setTimeout(() => {
49
+ reloadTimer = null;
50
+ if (!live() || !pending) return;
51
+ watchLoad();
52
+ try {
53
+ // Preserve the existing navigation controller, auth session and conversation.
54
+ if (!contents.isLoading()) contents.reload();
55
+ } catch {
56
+ clearTimers();
57
+ pending = false;
58
+ unavailable('reload-failed');
59
+ }
60
+ }, retryDelayMs);
61
+ });
62
+ on(contents, 'did-start-loading', () => {
63
+ if (!live() || !pending) return;
64
+ // A user's manual Reload can win the race with our queued recovery.
65
+ if (reloadTimer !== null) scheduler.clearTimeout(reloadTimer);
66
+ reloadTimer = null;
67
+ watchLoad();
68
+ });
69
+ on(contents, 'did-finish-load', () => {
70
+ if (!live() || !pending || contents.getOSProcessId() <= 0) return;
71
+ clearTimers();
72
+ pending = false;
73
+ try { repaint(); } catch { record('renderer-repaint-failed'); }
74
+ record('renderer-recovery-complete', {rendererPid: contents.getOSProcessId()});
75
+ });
76
+ on(app, 'before-quit', () => { quitting = true; pending = false; clearTimers(); });
77
+ on(window, 'close', () => {
78
+ closing = true;
79
+ pending = false;
80
+ clearTimers();
81
+ // A cancelled close must not disable recovery for the remaining window.
82
+ queueMicrotask(() => { if (!disposed && !quitting && !window.isDestroyed()) closing = false; });
83
+ });
84
+ on(contents, 'will-prevent-unload', () => { quitting = false; closing = false; });
85
+ function dispose() {
86
+ if (disposed) return;
87
+ disposed = true;
88
+ clearTimers();
89
+ for (const remove of listeners) remove();
90
+ }
91
+ on(window, 'closed', dispose);
92
+ on(contents, 'destroyed', dispose);
93
+ record('renderer-recovery-attached');
94
+ return dispose;
95
+ }