diff --git a/apps/desktop/electron/desktop-installation.test.ts b/apps/desktop/electron/desktop-installation.test.ts index 9be308a121..a9a0bbe829 100644 --- a/apps/desktop/electron/desktop-installation.test.ts +++ b/apps/desktop/electron/desktop-installation.test.ts @@ -268,7 +268,11 @@ test('the repair lock records our creation time from Electron, without a ps spaw try { asMacOS( () => 'Sun Oct 4 12:00:00 2026', - () => assert.equal(loadOrCreateInstallationId(filePath, () => ID_A), ID_A) + () => + assert.equal( + loadOrCreateInstallationId(filePath, () => ID_A), + ID_A + ) ) lockBody = String(writes.mock.calls[0]?.[1]) } finally { @@ -295,7 +299,11 @@ test('a contended repair lock probes its holder creation time once per wait, liv // The holder matches its recorded creation time, then dies into a zombie on the 5th poll. asMacOS( args => (args.includes('lstart=') ? 'Sun Oct 4 12:00:00 2026' : ++statPolls >= 5 ? 'Z' : 'S'), - () => assert.equal(loadOrCreateInstallationId(filePath, () => ID_A), ID_A) + () => + assert.equal( + loadOrCreateInstallationId(filePath, () => ID_A), + ID_A + ) ) assert.equal(statPolls, 5, 'liveness is re-read every poll') diff --git a/apps/desktop/electron/desktop-installation.ts b/apps/desktop/electron/desktop-installation.ts index 11241ca26c..1e6b9d8e37 100644 --- a/apps/desktop/electron/desktop-installation.ts +++ b/apps/desktop/electron/desktop-installation.ts @@ -158,7 +158,11 @@ function dropReclaimClaim(claim: string) { } /** Count consecutive empty-lock polls, then reclaim a dead lock or back off. */ -function waitOnContendedRepairLock(repairPath: string, emptyPolls: number, holderIsLive: RepairLockHolderJudge): number { +function waitOnContendedRepairLock( + repairPath: string, + emptyPolls: number, + holderIsLive: RepairLockHolderJudge +): number { let polls: number try { diff --git a/apps/desktop/electron/desktop-uninstall-ipc.test.ts b/apps/desktop/electron/desktop-uninstall-ipc.test.ts index 4c11a22339..07d8d3f017 100644 --- a/apps/desktop/electron/desktop-uninstall-ipc.test.ts +++ b/apps/desktop/electron/desktop-uninstall-ipc.test.ts @@ -126,7 +126,11 @@ test('self-managed installs retain summary and uninstall IPC behavior under Elec expect(ipc.probeSummary).toHaveBeenCalledOnce() expect(ipc.localSummary).not.toHaveBeenCalled() - ipc.probeSummary.mockResolvedValue({ ...fallbackSummary, code_removal_allowed: false, native_removal_instructions: null }) + ipc.probeSummary.mockResolvedValue({ + ...fallbackSummary, + code_removal_allowed: false, + native_removal_instructions: null + }) expect(await ipc.invoke('hermes:uninstall:summary')).toMatchObject({ probe: 'fallback', code_removal_allowed: true diff --git a/apps/desktop/electron/entry.ts b/apps/desktop/electron/entry.ts index 912e95ef61..abed288fe2 100644 --- a/apps/desktop/electron/entry.ts +++ b/apps/desktop/electron/entry.ts @@ -36,10 +36,12 @@ const electronFlags = linux ? configuredElectronFlags(process.env) : [] // True only when the NVIDIA proprietary kernel module is loaded // (not nouveau, not the open kernel module, not WSL's dxg passthrough). function hasNvidiaProprietaryDriver() { - if (process.platform !== 'linux') {return false} + if (process.platform !== 'linux') { + return false + } try { - const version = readFileSync('/proc/driver/nvidia/version', 'utf8') || "" + const version = readFileSync('/proc/driver/nvidia/version', 'utf8') || '' return !/Open Kernel Module/.test(version) } catch { @@ -47,7 +49,6 @@ function hasNvidiaProprietaryDriver() { } } - const args = wslgLaunchArgs( process.argv.slice(1), process.env, diff --git a/apps/desktop/electron/handoff-result-producer.test.ts b/apps/desktop/electron/handoff-result-producer.test.ts index 1ebc0e0871..de0578aa78 100644 --- a/apps/desktop/electron/handoff-result-producer.test.ts +++ b/apps/desktop/electron/handoff-result-producer.test.ts @@ -77,6 +77,7 @@ test.skipIf(process.platform === 'win32').each(['legacy', 'protocol-2'])( output += chunk }) const exited = once(child, 'exit') + // The hand-off hands the marker to a custodian that outlives it, so an old Desktop never // reads a dead owner; the log names that custodian together with this hand-off's pid. const custodian = () => { diff --git a/apps/desktop/electron/handoff-result.test.ts b/apps/desktop/electron/handoff-result.test.ts index c9f93a5110..c201f0d483 100644 --- a/apps/desktop/electron/handoff-result.test.ts +++ b/apps/desktop/electron/handoff-result.test.ts @@ -95,7 +95,10 @@ test('stable run identity outranks heartbeat time; only legacy receipts use time assert.ok(readAndConsumeHandoffResult(home, { expectedRunId: 'own-run', expectedStartedAt: started_at })) // ...and line 2 from a run that began after this result finished is not this run. write(home, receipt) - assert.equal(readAndConsumeHandoffResult(home, { expectedRunId: 'own-run', expectedStartedAt: finished_at + 60 }), null) + assert.equal( + readAndConsumeHandoffResult(home, { expectedRunId: 'own-run', expectedStartedAt: finished_at + 60 }), + null + ) // Older markers and post-update boots have no expected stable identity. write(home, { ...receipt, run_id: 'own-run' }) assert.ok(readAndConsumeHandoffResult(home, { expectedStartedAt: started_at })) @@ -112,10 +115,25 @@ test('a result from a run that finished before the parked one is discarded; a ma const home = tempHome() const now = Math.floor(Date.now() / 1000) const parked = now - 300 - write(home, { ok: false, exit_code: 1, message: 'old run', branch: 'main', started_at: now - 900, finished_at: now - 800 }) + write(home, { + ok: false, + exit_code: 1, + message: 'old run', + branch: 'main', + started_at: now - 900, + finished_at: now - 800 + }) assert.equal(readAndConsumeHandoffResult(home, { expectedStartedAt: parked }), null) - write(home, { ok: true, exit_code: 0, message: '', branch: 'main', started_at: parked, finished_at: now, warnings: ['gateway restart'] }) + write(home, { + ok: true, + exit_code: 0, + message: '', + branch: 'main', + started_at: parked, + finished_at: now, + warnings: ['gateway restart'] + }) assert.deepEqual(readAndConsumeHandoffResult(home, { expectedStartedAt: parked })?.warnings, ['gateway restart']) }) @@ -126,7 +144,14 @@ test('line 2 moved by the heartbeat inside the run still correlates its result', const home = tempHome() const now = Math.floor(Date.now() / 1000) const started_at = now - 900 - const result = { ok: false, exit_code: 3, message: 'failed after 15 min', branch: 'main', started_at, finished_at: now } + const result = { + ok: false, + exit_code: 3, + message: 'failed after 15 min', + branch: 'main', + started_at, + finished_at: now + } for (const heartbeat of [started_at + 300, started_at + 600, now]) { write(home, { ...result, run_id: 'script-run' }) diff --git a/apps/desktop/electron/handoff-result.ts b/apps/desktop/electron/handoff-result.ts index 37afb51d8b..c167d19da0 100644 --- a/apps/desktop/electron/handoff-result.ts +++ b/apps/desktop/electron/handoff-result.ts @@ -64,7 +64,9 @@ function consumeHandoffResultFile(file: string, log: (line: string) => void): { try { parsed = JSON.parse(raw) } catch (error) { - log(`[updates] hand-off result is not valid JSON (${(error as Error).message}); kept as ${path.basename(file)}.corrupt`) + log( + `[updates] hand-off result is not valid JSON (${(error as Error).message}); kept as ${path.basename(file)}.corrupt` + ) try { fs.renameSync(file, `${file}.corrupt`) @@ -102,7 +104,9 @@ function handoffRunMismatch( } if (expectedRunId !== null && runId !== undefined) { - return runId !== expectedRunId ? `[updates] hand-off result is for run ${runId}, not ${expectedRunId}; discarded` : null + return runId !== expectedRunId + ? `[updates] hand-off result is for run ${runId}, not ${expectedRunId}; discarded` + : null } if (expectedStartedAt !== null && Number.isFinite(startedAt) && !lineTwoWithinRun(expectedStartedAt, parsed)) { diff --git a/apps/desktop/electron/host-backend-singleton-orphan-reap.test.ts b/apps/desktop/electron/host-backend-singleton-orphan-reap.test.ts index 1bdcaec0bb..db14856cfd 100644 --- a/apps/desktop/electron/host-backend-singleton-orphan-reap.test.ts +++ b/apps/desktop/electron/host-backend-singleton-orphan-reap.test.ts @@ -17,11 +17,7 @@ import { test } from 'vitest' import { processStartMarker } from './backend-claim' import { BackendDialClaims } from './backend-dial-claim' -import { - type BackendIdentity, - type BackendOwnershipEntry, - createBackendOwnership -} from './backend-ownership' +import { type BackendIdentity, type BackendOwnershipEntry, createBackendOwnership } from './backend-ownership' import { assertNoSecondLocalBackend, SecondLocalBackendError } from './host-backend-singleton' import { isPidAlive } from './update-marker' @@ -65,9 +61,7 @@ test('#81275 storm: 12 concurrent same-scope reconnect dials coalesce to ONE spa // one wake. backendDialClaims is the seam main.ts wires at every dial site // (ensureTerminalBackend, media-stream resolveRemoteConnection, window // routes), so this is the production coalescing contract, not a model. - const results = await Promise.all( - Array.from({ length: 12 }, (_, attempt) => claims.run('default', dial)) - ) + const results = await Promise.all(Array.from({ length: 12 }, (_, attempt) => claims.run('default', dial))) assert.equal(spawns, 1, 'a same-scope reconnect storm must produce exactly one backend spawn') assert.equal(claims.inFlight('default'), false, 'the claim must release once the dial settles') @@ -124,160 +118,168 @@ test('#81275 singleton: a local pool spawn that routing missed still cannot reac assertNoSecondLocalBackend('worker', { unscopableRequest: true }) }) -test('#81275 orphan reap: a crashed parent leaves a REAL child that the next launch reaps', { timeout: 60_000 }, async () => { - // The issue's orphans were PPID-1 serve children left by a Desktop that - // quit before its backends. Simulate exactly the recorded shape: the - // ownership ledger knows the child (pid + start marker) and its parent; - // the parent is dead; the next launch's reapOrphans must stop the child. - // Real processes + the real platform start-marker probe (ps / /proc), so - // this exercises the same identity matching the packaged reaper uses. - if (process.platform === 'win32') { - return // Windows uses PowerShell Get-Process; covered by the Windows lane. - } - - const orphan = spawnDetachedChild() - const fakeParent = spawnDetachedChild() - - try { - const marker = await processStartMarker(orphan.pid) - const parentMarker = await processStartMarker(fakeParent.pid) - - const entry: BackendOwnershipEntry = { - nonce: 'reap-probe-nonce', - pid: orphan.pid, - profile: 'default', - startMarker: marker, - parentPid: fakeParent.pid, - parentStartMarker: parentMarker +test( + '#81275 orphan reap: a crashed parent leaves a REAL child that the next launch reaps', + { timeout: 60_000 }, + async () => { + // The issue's orphans were PPID-1 serve children left by a Desktop that + // quit before its backends. Simulate exactly the recorded shape: the + // ownership ledger knows the child (pid + start marker) and its parent; + // the parent is dead; the next launch's reapOrphans must stop the child. + // Real processes + the real platform start-marker probe (ps / /proc), so + // this exercises the same identity matching the packaged reaper uses. + if (process.platform === 'win32') { + return // Windows uses PowerShell Get-Process; covered by the Windows lane. } - let store = JSON.stringify({ backends: [entry] }) - const stopped: BackendIdentity[] = [] + const orphan = spawnDetachedChild() + const fakeParent = spawnDetachedChild() - const ownership = createBackendOwnership({ - // The REAL probe: the orphan is alive and its recorded start marker - // matches, so identity is confirmed — the reaper must stop it. - matchesIdentity: async identity => (await processStartMarker(identity.pid)) === identity.startMarker, - // The parent is dead (killed below), so parent liveness resolves via - // the same marker comparison main.ts's backendParentMatches performs. - matchesParent: async candidate => { - if (!candidate.parentPid || !candidate.parentStartMarker) { - return undefined + try { + const marker = await processStartMarker(orphan.pid) + const parentMarker = await processStartMarker(fakeParent.pid) + + const entry: BackendOwnershipEntry = { + nonce: 'reap-probe-nonce', + pid: orphan.pid, + profile: 'default', + startMarker: marker, + parentPid: fakeParent.pid, + parentStartMarker: parentMarker + } + + let store = JSON.stringify({ backends: [entry] }) + const stopped: BackendIdentity[] = [] + + const ownership = createBackendOwnership({ + // The REAL probe: the orphan is alive and its recorded start marker + // matches, so identity is confirmed — the reaper must stop it. + matchesIdentity: async identity => (await processStartMarker(identity.pid)) === identity.startMarker, + // The parent is dead (killed below), so parent liveness resolves via + // the same marker comparison main.ts's backendParentMatches performs. + matchesParent: async candidate => { + if (!candidate.parentPid || !candidate.parentStartMarker) { + return undefined + } + + try { + return (await processStartMarker(candidate.parentPid)) === candidate.parentStartMarker + } catch { + return false // ESRCH: the parent is gone. + } + }, + stop: async identity => { + try { + process.kill(identity.pid, 'SIGTERM') + } catch { + // Race with an external kill: still record the attempt. + } + + stopped.push(identity) + }, + store: { + read: () => store, + write: (next: string) => { + store = next + } } + }) + + // The parent dies first — the crash that orphans the backend child. + fakeParent.stop() + await new Promise(resolve => { + const timer = setTimeout(resolve, 500) + + void (async () => { + while (isPidAlive(fakeParent.pid)) { + await new Promise(wait => setTimeout(wait, 25)) + } + + clearTimeout(timer) + resolve() + })() + }) + assert.equal(isPidAlive(fakeParent.pid), false, 'fixture parent must be dead before the sweep') + + const reaped = await ownership.reapOrphans() + + assert.deepEqual(reaped, [orphan.pid], 'the sweep must reap the orphaned child') + assert.equal(stopped.length, 1, 'exactly one stop for the confirmed orphan') + + await new Promise(resolve => setTimeout(resolve, 300)) + assert.equal(isPidAlive(orphan.pid), false, 'the orphaned backend must actually be stopped') + + assert.doesNotThrow(() => { + const survivors = JSON.parse(store).backends as BackendOwnershipEntry[] + assert.equal( + survivors.some(candidate => candidate.pid === orphan.pid), + false, + 'a reaped backend must leave the ownership ledger' + ) + }) + } finally { + orphan.stop() + fakeParent.stop() + } + } +) + +test( + '#81275 orphan reap: a backend whose parent Electron is STILL alive is never reaped', + { timeout: 60_000 }, + async () => { + // #87295: the running instance's live backend is never an orphan, even when + // a second launch reaches reapOrphans. Same real-process probe, parent kept + // alive — the mirror image of the crash case and the guard that makes the + // reaper safe to run on every boot. + if (process.platform === 'win32') { + return + } + + const owned = spawnDetachedChild() + const liveParent = spawnDetachedChild() + + try { + const entry: BackendOwnershipEntry = { + nonce: 'live-parent-nonce', + pid: owned.pid, + profile: 'default', + startMarker: await processStartMarker(owned.pid), + parentPid: liveParent.pid, + parentStartMarker: await processStartMarker(liveParent.pid) + } + + let store = JSON.stringify({ backends: [entry] }) + let stopCalls = 0 + + const ownership = createBackendOwnership({ + matchesIdentity: async identity => (await processStartMarker(identity.pid)) === identity.startMarker, + matchesParent: async candidate => { + if (!candidate.parentPid || !candidate.parentStartMarker) { + return undefined + } - try { return (await processStartMarker(candidate.parentPid)) === candidate.parentStartMarker - } catch { - return false // ESRCH: the parent is gone. - } - }, - stop: async identity => { - try { - process.kill(identity.pid, 'SIGTERM') - } catch { - // Race with an external kill: still record the attempt. + }, + stop: async () => { + stopCalls += 1 + }, + store: { + read: () => store, + write: (next: string) => { + store = next + } } + }) - stopped.push(identity) - }, - store: { - read: () => store, - write: (next: string) => { - store = next - } - } - }) + const reaped = await ownership.reapOrphans() - // The parent dies first — the crash that orphans the backend child. - fakeParent.stop() - await new Promise(resolve => { - const timer = setTimeout(resolve, 500) - - void (async () => { - while (isPidAlive(fakeParent.pid)) { - await new Promise(wait => setTimeout(wait, 25)) - } - - clearTimeout(timer) - resolve() - })() - }) - assert.equal(isPidAlive(fakeParent.pid), false, 'fixture parent must be dead before the sweep') - - const reaped = await ownership.reapOrphans() - - assert.deepEqual(reaped, [orphan.pid], 'the sweep must reap the orphaned child') - assert.equal(stopped.length, 1, 'exactly one stop for the confirmed orphan') - - await new Promise(resolve => setTimeout(resolve, 300)) - assert.equal(isPidAlive(orphan.pid), false, 'the orphaned backend must actually be stopped') - - assert.doesNotThrow(() => { - const survivors = JSON.parse(store).backends as BackendOwnershipEntry[] - assert.equal( - survivors.some(candidate => candidate.pid === orphan.pid), - false, - 'a reaped backend must leave the ownership ledger' - ) - }) - } finally { - orphan.stop() - fakeParent.stop() - } -}) - -test('#81275 orphan reap: a backend whose parent Electron is STILL alive is never reaped', { timeout: 60_000 }, async () => { - // #87295: the running instance's live backend is never an orphan, even when - // a second launch reaches reapOrphans. Same real-process probe, parent kept - // alive — the mirror image of the crash case and the guard that makes the - // reaper safe to run on every boot. - if (process.platform === 'win32') { - return - } - - const owned = spawnDetachedChild() - const liveParent = spawnDetachedChild() - - try { - const entry: BackendOwnershipEntry = { - nonce: 'live-parent-nonce', - pid: owned.pid, - profile: 'default', - startMarker: await processStartMarker(owned.pid), - parentPid: liveParent.pid, - parentStartMarker: await processStartMarker(liveParent.pid) + assert.deepEqual(reaped, [], 'a live parent means the backend is not an orphan') + assert.equal(stopCalls, 0, "the sweep must never stop a live instance's backend") + assert.equal(isPidAlive(owned.pid), true, 'the owned backend survives the sweep') + } finally { + owned.stop() + liveParent.stop() } - - let store = JSON.stringify({ backends: [entry] }) - let stopCalls = 0 - - const ownership = createBackendOwnership({ - matchesIdentity: async identity => (await processStartMarker(identity.pid)) === identity.startMarker, - matchesParent: async candidate => { - if (!candidate.parentPid || !candidate.parentStartMarker) { - return undefined - } - - return (await processStartMarker(candidate.parentPid)) === candidate.parentStartMarker - }, - stop: async () => { - stopCalls += 1 - }, - store: { - read: () => store, - write: (next: string) => { - store = next - } - } - }) - - const reaped = await ownership.reapOrphans() - - assert.deepEqual(reaped, [], 'a live parent means the backend is not an orphan') - assert.equal(stopCalls, 0, 'the sweep must never stop a live instance\'s backend') - assert.equal(isPidAlive(owned.pid), true, 'the owned backend survives the sweep') - } finally { - owned.stop() - liveParent.stop() } -}) +) diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index b76a7e0588..39d10e9b2a 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -615,7 +615,12 @@ import { registerUpdateHoldIpc, waitForPoolUpdateClearance } from './update-hold-wiring' -import { describeSkippedPrewrite, readLiveUpdateMarker, updateHandoffConflict, writeUpdateMarker } from './update-marker' +import { + describeSkippedPrewrite, + readLiveUpdateMarker, + updateHandoffConflict, + writeUpdateMarker +} from './update-marker' import { heldWaitMessage, holdTicker } from './update-marker-gate' import { updateConnectionsBeforeLocal } from './update-order' import { diff --git a/apps/desktop/electron/remote-update-marker-programs.test.ts b/apps/desktop/electron/remote-update-marker-programs.test.ts index 8474433a5b..f95bf968d4 100644 --- a/apps/desktop/electron/remote-update-marker-programs.test.ts +++ b/apps/desktop/electron/remote-update-marker-programs.test.ts @@ -52,39 +52,57 @@ $script:live=@{};foreach($p in $case.live.PSObject.Properties){$script:live[[int } ` -const powershell = ['pwsh', 'powershell'].find(shell => spawnSync(shell, ['-NoProfile', '-Command', 'exit 0']).status === 0) +const powershell = ['pwsh', 'powershell'].find( + shell => spawnSync(shell, ['-NoProfile', '-Command', 'exit 0']).status === 0 +) -test.skipIf(!powershell)('the Windows remote marker judge agrees with every corpus judge case', async () => { - const corpus = JSON.parse(readFileSync(corpusPath, 'utf8')) - const dir = mkdtempSync(path.join(os.tmpdir(), 'hermes-remote-ps-judge-')) - const script = path.join(dir, 'replay.ps1') +test.skipIf(!powershell)( + 'the Windows remote marker judge agrees with every corpus judge case', + async () => { + const corpus = JSON.parse(readFileSync(corpusPath, 'utf8')) + const dir = mkdtempSync(path.join(os.tmpdir(), 'hermes-remote-ps-judge-')) + const script = path.join(dir, 'replay.ps1') - try { - writeFileSync(script, `${WINDOWS_MARKER_JUDGE_PS}\n${PS_DRIVER}`) - const { stdout } = await execFile(powershell!, ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', script, corpusPath]) - const verdicts = Object.fromEntries(stdout.trim().split(/\r?\n/).map(line => line.split('\t'))) + try { + writeFileSync(script, `${WINDOWS_MARKER_JUDGE_PS}\n${PS_DRIVER}`) + const { stdout } = await execFile(powershell!, [ + '-NoProfile', + '-ExecutionPolicy', + 'Bypass', + '-File', + script, + corpusPath + ]) + const verdicts = Object.fromEntries( + stdout + .trim() + .split(/\r?\n/) + .map(line => line.split('\t')) + ) - // Only the host shell is "us" to this judge (never a marker owner), so the - // corpus cases about the reader's own pid do not apply. - const cases = corpus.judge.filter((c: any) => { - const marker = parseUpdateMarker(c.text) + // Only the host shell is "us" to this judge (never a marker owner), so the + // corpus cases about the reader's own pid do not apply. + const cases = corpus.judge.filter((c: any) => { + const marker = parseUpdateMarker(c.text) - return ![marker?.pid, marker?.delegate?.pid].includes(c.our_pid ?? corpus.our_pid) - }) + return ![marker?.pid, marker?.delegate?.pid].includes(c.our_pid ?? corpus.our_pid) + }) - const verdict = (c: any) => - ({ malformed: 'UNCERTAIN', dead: 'CLEAR', live: `LIVE:${c.expect.owner}` })[c.expect.verdict as string] + const verdict = (c: any) => + ({ malformed: 'UNCERTAIN', dead: 'CLEAR', live: `LIVE:${c.expect.owner}` })[c.expect.verdict as string] - assert.ok(cases.length >= 40) - assert.deepEqual( - Object.fromEntries(cases.map((c: any) => [c.name, verdicts[c.name]])), - Object.fromEntries(cases.map((c: any) => [c.name, verdict(c)])) - ) - } finally { - rmSync(dir, { recursive: true, force: true }) - } - // pwsh cold start alone can take seconds on a loaded runner; same budget as the other subprocess tests. -}, 20_000) + assert.ok(cases.length >= 40) + assert.deepEqual( + Object.fromEntries(cases.map((c: any) => [c.name, verdicts[c.name]])), + Object.fromEntries(cases.map((c: any) => [c.name, verdict(c)])) + ) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + // pwsh cold start alone can take seconds on a loaded runner; same budget as the other subprocess tests. + }, + 20_000 +) // A dead claim whose checkout lock is still flocked (a killed updater's completion // child) must be kept: the gate answers HELD instead of unlinking it (review G1). diff --git a/apps/desktop/electron/update-marker-corpus.test.ts b/apps/desktop/electron/update-marker-corpus.test.ts index 1b3a838606..c535cc81b9 100644 --- a/apps/desktop/electron/update-marker-corpus.test.ts +++ b/apps/desktop/electron/update-marker-corpus.test.ts @@ -132,7 +132,17 @@ describe('Electron never mutates a marker (A7 rule 3)', () => { for (const name of ['update-marker.ts', 'update-marker-judge.ts', 'update-marker-gate.ts']) { const source = fs.readFileSync(path.join(__dirname, name), 'utf8') - for (const mutator of ['unlinkSync', 'unlink(', 'renameSync', 'rename(', 'truncate', 'writeFileSync', "'w'", "'w+'", "'a'"]) { + for (const mutator of [ + 'unlinkSync', + 'unlink(', + 'renameSync', + 'rename(', + 'truncate', + 'writeFileSync', + "'w'", + "'w+'", + "'a'" + ]) { assert.ok(!source.includes(mutator), `${name} must not call ${mutator}`) } } diff --git a/apps/desktop/electron/update-marker-gate.test.ts b/apps/desktop/electron/update-marker-gate.test.ts index 9af8d793f5..1b06664d53 100644 --- a/apps/desktop/electron/update-marker-gate.test.ts +++ b/apps/desktop/electron/update-marker-gate.test.ts @@ -206,7 +206,10 @@ describe.skipIf(process.platform === 'win32')('gate over a dead marker (R6)', () clock += HELD_REPROBE_MS } - assert.ok(states.every(s => s.verdict === 'error' && !s.blocking), 'retries are not a hold: no blocked screen') + assert.ok( + states.every(s => s.verdict === 'error' && !s.blocking), + 'retries are not a hold: no blocked screen' + ) assert.equal(await probe(), false, 'retries used up: proceed as for any dead marker') assert.equal(helperCalls(home).length, HELPER_ERROR_ATTEMPTS) assert.equal(await gate(root, home, () => clock)(), false, 'a later wait (pool backend) proceeds at once') @@ -358,7 +361,11 @@ describe.skipIf(process.platform === 'win32')('gate over a dead marker (R6)', () for (const answer of ['garbage', 'busy']) { fs.writeFileSync(path.join(home, 'helper-verdict'), answer) - assert.equal(await gate(root, home)(), false, `a helper that cannot answer (${answer}) never blocks a markerless boot`) + assert.equal( + await gate(root, home)(), + false, + `a helper that cannot answer (${answer}) never blocks a markerless boot` + ) } }) }) diff --git a/apps/desktop/electron/update-marker-gate.ts b/apps/desktop/electron/update-marker-gate.ts index 7766196864..cb7ed1f6b5 100644 --- a/apps/desktop/electron/update-marker-gate.ts +++ b/apps/desktop/electron/update-marker-gate.ts @@ -281,14 +281,19 @@ async function refreshVerdict( } if (!previous || STILL_RUNNING.has(previous.verdict.kind) !== STILL_RUNNING.has(verdict.kind)) { - log?.( - `[updates] ${subject}: script helper says ${verdict.kind}${'pid' in verdict ? ` ${verdict.pid}` : ''}` - ) + log?.(`[updates] ${subject}: script helper says ${verdict.kind}${'pid' in verdict ? ` ${verdict.pid}` : ''}`) } const errors = verdict.kind === 'error' ? (previous?.errors ?? 0) + 1 : 0 - return { ...previous, verdict, at: now(), generation, errors, answered: previous?.answered || verdict.kind !== 'error' } + return { + ...previous, + verdict, + at: now(), + generation, + errors, + answered: previous?.answered || verdict.kind !== 'error' + } } /** The helper never answered for this body and has used its retries: proceed as main did for a dead marker. */ @@ -338,7 +343,13 @@ function helperSubject( return null } - return { holdId: ABSENT_HOLD_ID, absent: true, label: 'no update marker', ownerPid: null, run: { startedAt: null, runId: null } } + return { + holdId: ABSENT_HOLD_ID, + absent: true, + label: 'no update marker', + ownerPid: null, + run: { startedAt: null, runId: null } + } } if (inspection.state !== 'dead') { @@ -411,7 +422,9 @@ export function liveMarkerProbe({ if (await failedAfterV1Start(hermesHome, inspection.marker)) { if (!finishedLogged) { finishedLogged = true - log?.('[updates] latest update receipt records a failure after this v1 marker started; not parking the boot on it') + log?.( + '[updates] latest update receipt records a failure after this v1 marker started; not parking the boot on it' + ) } return false diff --git a/apps/desktop/electron/update-marker-readonly.test.ts b/apps/desktop/electron/update-marker-readonly.test.ts index 06e7958bcc..3219c2f2bf 100644 --- a/apps/desktop/electron/update-marker-readonly.test.ts +++ b/apps/desktop/electron/update-marker-readonly.test.ts @@ -29,7 +29,10 @@ async function deadMarkerBodies(): Promise<[string, string][]> { return [ ['dead v2 owner', `${gone}\n${minutesAgo(1)}\nct:1700000000.000\n`], ['dead v1 owner', `${gone}\n${minutesAgo(1)}\n`], - ['dead owner + dead delegate (crash-12db shape)', `${gone}\n${minutesAgo(1)}\nct:1.000\ndelegate:${gone} ct:2.000\n`], + [ + 'dead owner + dead delegate (crash-12db shape)', + `${gone}\n${minutesAgo(1)}\nct:1.000\ndelegate:${gone} ct:2.000\n` + ], ['malformed', 'not-a-pid\nnonsense\n'], ['previous incarnation of our own pid', `${process.pid}\n${minutesAgo(1)}\n`] ] diff --git a/apps/desktop/electron/update-marker.test.ts b/apps/desktop/electron/update-marker.test.ts index d1b6ed7d43..264ea69abd 100644 --- a/apps/desktop/electron/update-marker.test.ts +++ b/apps/desktop/electron/update-marker.test.ts @@ -96,14 +96,30 @@ test('parses v1, v2 and the delegate line; garbage is null', () => { // A2: one positional parse in every language (Python, Rust, PowerShell, bash, TS). test.each([ - ['BOM + CRLF v2', '\uFEFF42\r\n100\r\nct:5.250\r\n', { pid: 42, startedAt: 100, ct: 5.25, delegate: null, run: null }], + [ + 'BOM + CRLF v2', + '\uFEFF42\r\n100\r\nct:5.250\r\n', + { pid: 42, startedAt: 100, ct: 5.25, delegate: null, run: null } + ], ['fractional started_at is MALFORMED', '42\n100.5\nct:5.000\n', null], ['missing started_at is MALFORMED', '42\n', null], ['garbled started_at is MALFORMED', '42\nsoon\n', null], ['garbage ct => v1', '42\n100\nct:abc\n', { pid: 42, startedAt: 100, ct: null, delegate: null, run: null }], - ['delegate on line 3 is ignored', '42\n100\ndelegate:77 ct:1.000\n', { pid: 42, startedAt: 100, ct: null, delegate: null, run: null }], - ['space after delegate: is ignored', '42\n100\nct:1.000\ndelegate: 77 ct:1.000\n', { pid: 42, startedAt: 100, ct: 1, delegate: null, run: null }], - ['delegate without ct is ignored', '42\n100\nct:1.000\ndelegate:77\n', { pid: 42, startedAt: 100, ct: 1, delegate: null, run: null }], + [ + 'delegate on line 3 is ignored', + '42\n100\ndelegate:77 ct:1.000\n', + { pid: 42, startedAt: 100, ct: null, delegate: null, run: null } + ], + [ + 'space after delegate: is ignored', + '42\n100\nct:1.000\ndelegate: 77 ct:1.000\n', + { pid: 42, startedAt: 100, ct: 1, delegate: null, run: null } + ], + [ + 'delegate without ct is ignored', + '42\n100\nct:1.000\ndelegate:77\n', + { pid: 42, startedAt: 100, ct: 1, delegate: null, run: null } + ], [ 'empty ct line (Python probe failure) + delegate on line 4', '42\n100\n\ndelegate:77 ct:2.500\n', @@ -131,23 +147,29 @@ test.skipIf(!HAS_CT_PROBE)('a LIVE v2 owner past 20 minutes stays live and is NO assert.ok(fs.existsSync(markerPath(home)), 'a live owner is never aged out') }) -test.skipIf(!HAS_CT_PROBE)('a reused pid (creation time mismatch) is dead and left for the script (V22, A7 rule 3)', async () => { - const home = tmpHome('v2-reused') - const owner = await liveOwner() - const ct = await processCreateTime(owner.pid) - // The marker names the pid the live process now holds, but a creation time - // an hour earlier: the original owner died and the OS recycled its pid. - fs.writeFileSync(markerPath(home), `${owner.pid}\n${minutesAgo(2)}\nct:${formatCreateTime(ct! - 3600)}\n`) +test.skipIf(!HAS_CT_PROBE)( + 'a reused pid (creation time mismatch) is dead and left for the script (V22, A7 rule 3)', + async () => { + const home = tmpHome('v2-reused') + const owner = await liveOwner() + const ct = await processCreateTime(owner.pid) + // The marker names the pid the live process now holds, but a creation time + // an hour earlier: the original owner died and the OS recycled its pid. + fs.writeFileSync(markerPath(home), `${owner.pid}\n${minutesAgo(2)}\nct:${formatCreateTime(ct! - 3600)}\n`) - const body = fs.readFileSync(markerPath(home), 'utf8') - assert.equal(await readLiveUpdateMarker(home), null) - assert.equal(fs.readFileSync(markerPath(home), 'utf8'), body, 'Electron never deletes; the script helper reclaims') -}) + const body = fs.readFileSync(markerPath(home), 'utf8') + assert.equal(await readLiveUpdateMarker(home), null) + assert.equal(fs.readFileSync(markerPath(home), 'utf8'), body, 'Electron never deletes; the script helper reclaims') + } +) test('a v1 marker (no creation time) keeps the legacy 20-minute ceiling', async () => { const home = tmpHome('v1-old') const owner = await liveOwner() - fs.writeFileSync(markerPath(home), `${owner.pid}\n${Math.floor((Date.now() - UPDATE_MARKER_MAX_AGE_MS) / 1000) - 60}\n`) + fs.writeFileSync( + markerPath(home), + `${owner.pid}\n${Math.floor((Date.now() - UPDATE_MARKER_MAX_AGE_MS) / 1000) - 60}\n` + ) assert.equal(await readLiveUpdateMarker(home), null, 'v1 pid reuse reads as not running') assert.ok(fs.existsSync(markerPath(home)), 'and is left for the script helper (A7 rule 3)') @@ -166,32 +188,43 @@ test('a v2 owner whose creation time is UNREADABLE is live only inside the 20-mi fs.writeFileSync(markerPath(home), `${owner.pid}\n${minutesAgo(25)}\nct:1700000000.000\n`) assert.equal(await readLiveUpdateMarker(home, { createTime: accessDenied }), null, 'past the ceiling it is dead') - assert.ok(fs.existsSync(markerPath(home)), 'not running, so boot is never parked forever — and left in place (A7 rule 3)') + assert.ok( + fs.existsSync(markerPath(home)), + 'not running, so boot is never parked forever — and left in place (A7 rule 3)' + ) }) // A1 on a real Windows host: csrss.exe is a SYSTEM (protected) process whose // Get-Process .StartTime is access-denied. CIM still answers, so a marker // whose pid was reused by such a process is judged by creation time — DEAD — // instead of "unknown, live forever". -test.runIf(process.platform === 'win32')('Windows reads a SYSTEM process creation time; a reused pid is dead (A1)', async () => { - const home = tmpHome('win-system') +test.runIf(process.platform === 'win32')( + 'Windows reads a SYSTEM process creation time; a reused pid is dead (A1)', + async () => { + const home = tmpHome('win-system') - const pid = Number( - execFileSync( - 'powershell.exe', - ['-NoProfile', '-NonInteractive', '-Command', "(Get-CimInstance Win32_Process -Filter \"Name='csrss.exe'\" | Select-Object -First 1).ProcessId"], - { encoding: 'utf8' } - ).trim() - ) + const pid = Number( + execFileSync( + 'powershell.exe', + [ + '-NoProfile', + '-NonInteractive', + '-Command', + '(Get-CimInstance Win32_Process -Filter "Name=\'csrss.exe\'" | Select-Object -First 1).ProcessId' + ], + { encoding: 'utf8' } + ).trim() + ) - assert.ok(pid > 0, 'csrss.exe runs on every Windows session') - const ct = await processCreateTime(pid) - assert.ok(ct !== null && ct > 1e9 && ct <= Date.now() / 1000, `creation time of SYSTEM pid ${pid}: ${ct}`) + assert.ok(pid > 0, 'csrss.exe runs on every Windows session') + const ct = await processCreateTime(pid) + assert.ok(ct !== null && ct > 1e9 && ct <= Date.now() / 1000, `creation time of SYSTEM pid ${pid}: ${ct}`) - fs.writeFileSync(markerPath(home), `${pid}\n${minutesAgo(1)}\nct:${formatCreateTime(ct! - 3600)}\n`) - assert.equal(await readLiveUpdateMarker(home), null) - assert.ok(fs.existsSync(markerPath(home)), 'left in place (A7 rule 3)') -}) + fs.writeFileSync(markerPath(home), `${pid}\n${minutesAgo(1)}\nct:${formatCreateTime(ct! - 3600)}\n`) + assert.equal(await readLiveUpdateMarker(home), null) + assert.ok(fs.existsSync(markerPath(home)), 'left in place (A7 rule 3)') + } +) test('a gate wait probes each pid creation time ONCE (A1: no powershell spawn per poll)', async () => { const home = tmpHome('ct-cache') @@ -311,7 +344,10 @@ test('the bridge marker names THIS process with its creation time (V4)', async ( test('the bridge claim refuses a LIVE foreign owner and reports (never reclaims) a dead one', async () => { const home = tmpHome('bridge-conflict') const owner = await liveOwner() - fs.writeFileSync(markerPath(home), `${owner.pid}\n${minutesAgo(30)}\nct:${formatCreateTime((await processCreateTime(owner.pid)) ?? 0)}\n`) + fs.writeFileSync( + markerPath(home), + `${owner.pid}\n${minutesAgo(30)}\nct:${formatCreateTime((await processCreateTime(owner.pid)) ?? 0)}\n` + ) const refused = await claimBridgeMarker(home, { startedAt: 1 }) assert.equal(refused.ok, false) diff --git a/apps/desktop/electron/updater/checkout-handoff.test.ts b/apps/desktop/electron/updater/checkout-handoff.test.ts index c736bacc1b..62ca7f9e68 100644 --- a/apps/desktop/electron/updater/checkout-handoff.test.ts +++ b/apps/desktop/electron/updater/checkout-handoff.test.ts @@ -184,9 +184,9 @@ it('a failed hand-off spawn keeps the app alive and reports the failure in plain // A legacy script (no protocol line) gets no bridge at all (SPEC 4b). it('a hand-off whose legacy script never takes the marker keeps the app alive and wrote no bridge', async (): Promise => { const { root, deps } = handoffFixture(false) - vi.spyOn(updaterProcess, 'spawnUpdaterProcess').mockImplementation( - (): updaterProcess.UpdaterChild => ({ unref: (): void => {} }) - ) + vi.spyOn(updaterProcess, 'spawnUpdaterProcess').mockImplementation((): updaterProcess.UpdaterChild => ({ + unref: (): void => {} + })) try { const result = await createCheckoutStrategy({ ...deps, handoffClaimTimeoutMs: 300 }).apply() @@ -328,8 +328,6 @@ function mockProtocol2Spawn(home: string, script: 'adopts' | 'adopts-then-dies' return runs } - - describe.skipIf(IS_WINDOWS)('protocol 2 hand-off', () => { it('bridges with a run id, passes --handoff-run, and acknowledges the live adopting script', async (): Promise => { const { root, deps } = handoffFixture(false, fakeHelperScript()) @@ -536,7 +534,10 @@ it('the Windows protocol-2 hand-off passes -HandoffRun with the bridged run id', expect(parseUpdateMarker(bridge ?? '')?.run).toBe(runId) expect(argAfter(runs[0]!, '-DesktopPid')).toBe(String(process.pid)) // Nothing adopted it: the bridge is withdrawn through the helper, with the run. - expect(helper).toHaveBeenCalledWith('withdraw', expect.objectContaining({ runId, desktopPid: process.pid, isWindows: true })) + expect(helper).toHaveBeenCalledWith( + 'withdraw', + expect.objectContaining({ runId, desktopPid: process.pid, isWindows: true }) + ) } finally { fs.rmSync(root, { recursive: true, force: true }) } diff --git a/apps/desktop/electron/updater/marker-helper.test.ts b/apps/desktop/electron/updater/marker-helper.test.ts index 10d8478728..d3d278178e 100644 --- a/apps/desktop/electron/updater/marker-helper.test.ts +++ b/apps/desktop/electron/updater/marker-helper.test.ts @@ -102,7 +102,10 @@ describe.skipIf(process.platform === 'win32')('runMarkerHelper against a real sc assert.deepEqual(await runMarkerHelper('reclaim', { updateRoot: root, hermesHome: home, isWindows: false }), { kind: 'absent' }) - assert.equal(fs.readFileSync(path.join(home, 'helper-calls.log'), 'utf8'), `--marker-op reclaim --install-root ${root}\n`) + assert.equal( + fs.readFileSync(path.join(home, 'helper-calls.log'), 'utf8'), + `--marker-op reclaim --install-root ${root}\n` + ) }) test.each([ diff --git a/apps/desktop/electron/updater/marker-helper.ts b/apps/desktop/electron/updater/marker-helper.ts index c0ce06be1c..779e23a1d3 100644 --- a/apps/desktop/electron/updater/marker-helper.ts +++ b/apps/desktop/electron/updater/marker-helper.ts @@ -151,7 +151,10 @@ const OP_VERDICTS: Record> = { /** Parse the helper's one verdict line; anything else is an operational error. */ export function parseMarkerHelperVerdict(stdout: string): MarkerHelperVerdict { - const lines = stdout.split(/\r?\n/).map(line => line.trim()).filter(Boolean) + const lines = stdout + .split(/\r?\n/) + .map(line => line.trim()) + .filter(Boolean) if (lines.length !== 1) { return { kind: 'error' } @@ -180,7 +183,12 @@ export function parseMarkerHelperVerdict(stdout: string): MarkerHelperVerdict { export function markerHelperCommand( op: MarkerHelperOp, scriptPath: string, - { updateRoot, desktopPid, runId, isWindows }: Pick + { + updateRoot, + desktopPid, + runId, + isWindows + }: Pick ): { command: string; args: string[] } { const hasPid = typeof desktopPid === 'number' && Number.isInteger(desktopPid) && desktopPid > 0 diff --git a/apps/desktop/electron/windows-remote-lifecycle.test.ts b/apps/desktop/electron/windows-remote-lifecycle.test.ts index 9cd509f8af..6b56cc2fd2 100644 --- a/apps/desktop/electron/windows-remote-lifecycle.test.ts +++ b/apps/desktop/electron/windows-remote-lifecycle.test.ts @@ -268,9 +268,14 @@ test('Windows gates keep a dead marker while the checkout lock or a lease byte i } const deleteAt = spawnScript.indexOf('[IO.File]::Delete($marker)') - const guardAt = spawnScript.indexOf('if($verdict -eq "CLEAR" -and (Test-CheckoutLockHeld $checkoutRoots)){$verdict="HELD"}') + const guardAt = spawnScript.indexOf( + 'if($verdict -eq "CLEAR" -and (Test-CheckoutLockHeld $checkoutRoots)){$verdict="HELD"}' + ) assert.ok(guardAt > 0 && guardAt < deleteAt, 'the checkout probe must precede the dead-marker delete') - assert.match(probeScript, /if\(\$result -eq "CLEAR" -and \(Test-CheckoutLockHeld \$checkoutRoots\)\)\{\$result="HELD"\}/) + assert.match( + probeScript, + /if\(\$result -eq "CLEAR" -and \(Test-CheckoutLockHeld \$checkoutRoots\)\)\{\$result="HELD"\}/ + ) }) test('Windows relaunch gate uses strict install-wide marker parsing and fail-closed PID probing', async () => { @@ -439,7 +444,6 @@ test('every parsed Windows PowerShell script silences the progress stream', asyn } }) - test('helper parsing ignores CLIXML progress blocks around its JSON line', async () => { const payload = JSON.stringify({ supported: true, version: '1.2.3' }) @@ -601,7 +605,8 @@ test.skipIf(process.platform !== 'win32')('Windows platform probe executes in re assert.equal(result.python, path.join(tempDir, 'python.exe')) } finally { fs.rmSync(tempDir, { recursive: true, force: true }) - }}) + } +}) test('platform detection preserves POSIX and falls back to Windows PowerShell', async () => { assert.deepEqual(await detectRemotePlatform(sshWith(async () => 'Linux\nx86_64\n')), { os: 'Linux', arch: 'x86_64' }) diff --git a/apps/desktop/electron/windows-remote-lifecycle.ts b/apps/desktop/electron/windows-remote-lifecycle.ts index ac79b13aa0..6f05c4aded 100644 --- a/apps/desktop/electron/windows-remote-lifecycle.ts +++ b/apps/desktop/electron/windows-remote-lifecycle.ts @@ -103,6 +103,7 @@ async function probeWindowsRemote(ssh, explicitHermesPath = '') { const lines = stripPowerShellNoise( await ssh.exec(powerShellStdinCommand(), { stdinData: `${encodedPowerShell(script)}\r\n` }) ) + const parsed = JSON.parse(lines[lines.length - 1] || 'null') if (!parsed?.os || !parsed?.arch) { @@ -204,11 +205,12 @@ async function assertWindowsRemoteInstallUpdateClear(ssh, hermesHome, python = ' // Same stdout channel as the probe: a CLIXML progress block after the // final `Write-Output $result` would otherwise win the .pop() and turn a // CLEAR gate into a fail-closed 'update-in-progress' verdict. - observation = stripPowerShellNoise( - await ssh.exec(powerShellStdinCommand(), { - stdinData: windowsUpdateMarkerProbeStdinData(hermesHome, python) - }) - ).pop() || '' + observation = + stripPowerShellNoise( + await ssh.exec(powerShellStdinCommand(), { + stdinData: windowsUpdateMarkerProbeStdinData(hermesHome, python) + }) + ).pop() || '' } catch (cause) { const error: any = new Error('Could not prove that the remote Hermes install is clear for SSH startup.') error.kind = 'update-in-progress' diff --git a/apps/desktop/src/api/messaging.ts b/apps/desktop/src/api/messaging.ts index 65da1c8951..706c0c8b62 100644 --- a/apps/desktop/src/api/messaging.ts +++ b/apps/desktop/src/api/messaging.ts @@ -169,10 +169,7 @@ export function enableWebhooks(profile?: null | string): Promise { +export function createWebhook(body: WebhookCreatePayload, profile?: null | string): Promise { return hermesApi({ ...profileScoped(profile), path: '/api/webhooks', diff --git a/apps/desktop/src/api/system.ts b/apps/desktop/src/api/system.ts index 8492857aae..b3af668e68 100644 --- a/apps/desktop/src/api/system.ts +++ b/apps/desktop/src/api/system.ts @@ -171,11 +171,7 @@ export function checkHermesUpdate(force = false): Promise { +export function getActionStatus(name: string, lines = 200, profile?: ProfileScope): Promise { return window.hermesDesktop.api({ ...capabilityScoped(profile), path: `/api/actions/${encodeURIComponent(name)}/status?lines=${Math.max(1, lines)}` diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts index 4ed5e36308..cb7dea22ff 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts @@ -350,11 +350,7 @@ export function useComposerDraft({ ) }, [inputDisabled, paintDraft, target]) - const loadIntoComposer = ( - text: string, - attachments: ComposerAttachment[], - preserveFocusedCaret = false - ) => { + const loadIntoComposer = (text: string, attachments: ComposerAttachment[], preserveFocusedCaret = false) => { // Diagnostic breadcrumb for #59305-class reports: identifies WHAT kind of // state got restored into the composer (session switch, queue-edit // restore, history browse) without logging any raw content. REF_RE has the diff --git a/apps/desktop/src/app/chat/composer/hooks/use-voice-recorder.ts b/apps/desktop/src/app/chat/composer/hooks/use-voice-recorder.ts index 97647b8ce8..fbb3845d73 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-voice-recorder.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-voice-recorder.ts @@ -115,7 +115,8 @@ export function useVoiceRecorder({ return } - const transcript = (await liveTranscript(dictation)) ?? (await onTranscribeAudio(result.audio, dictation.owner)).trim() + const transcript = + (await liveTranscript(dictation)) ?? (await onTranscribeAudio(result.audio, dictation.owner)).trim() if (!live()) { return diff --git a/apps/desktop/src/app/chat/sidebar/project-dialog.test.tsx b/apps/desktop/src/app/chat/sidebar/project-dialog.test.tsx index 3b85ecb62f..e458794e85 100644 --- a/apps/desktop/src/app/chat/sidebar/project-dialog.test.tsx +++ b/apps/desktop/src/app/chat/sidebar/project-dialog.test.tsx @@ -39,21 +39,22 @@ vi.mock('@/i18n', () => ({ // interactions under test. // vi.mock factories are hoisted above the rest of the file, so the atom must // be created inside vi.hoisted to exist by the time the factory runs. -const { $newProjectDropPlacement, $projectDialog, addProjectFolders, createProject, enterProject, pickProjectFolders } = vi.hoisted(() => { - const { atom } = require('nanostores') as typeof Nanostores +const { $newProjectDropPlacement, $projectDialog, addProjectFolders, createProject, enterProject, pickProjectFolders } = + vi.hoisted(() => { + const { atom } = require('nanostores') as typeof Nanostores - return { - // Where a "New project" DRAG armed its drop (null = plain click). - $newProjectDropPlacement: atom<{ anchor: string; before?: null | string; dir: string } | null>(null), - $projectDialog: atom<{ mode: 'create' | 'rename' | 'add-folder'; name?: string; projectId?: string } | null>({ - mode: 'create' - }), - addProjectFolders: vi.fn(), - createProject: vi.fn(), - enterProject: vi.fn(), - pickProjectFolders: vi.fn() - } -}) + return { + // Where a "New project" DRAG armed its drop (null = plain click). + $newProjectDropPlacement: atom<{ anchor: string; before?: null | string; dir: string } | null>(null), + $projectDialog: atom<{ mode: 'create' | 'rename' | 'add-folder'; name?: string; projectId?: string } | null>({ + mode: 'create' + }), + addProjectFolders: vi.fn(), + createProject: vi.fn(), + enterProject: vi.fn(), + pickProjectFolders: vi.fn() + } + }) vi.mock('@/store/projects', () => ({ $newProjectDropPlacement, diff --git a/apps/desktop/src/app/contrib/wiring-background-queue-drain.test.tsx b/apps/desktop/src/app/contrib/wiring-background-queue-drain.test.tsx index dbe0e677b1..9a2fe560b7 100644 --- a/apps/desktop/src/app/contrib/wiring-background-queue-drain.test.tsx +++ b/apps/desktop/src/app/contrib/wiring-background-queue-drain.test.tsx @@ -10,7 +10,14 @@ import { getQueuedPrompts } from '@/store/composer-queue' import { $activeConnectionId } from '@/store/connections' -import { $activeSessionId, $connection, $gatewayState, $selectedStoredSessionId, setSessions, setSessionsLoading } from '@/store/session' +import { + $activeSessionId, + $connection, + $gatewayState, + $selectedStoredSessionId, + setSessions, + setSessionsLoading +} from '@/store/session' import { clearAllSessionStates } from '@/store/session-states' import { makeSessionInfo } from '@/test/session-info' diff --git a/apps/desktop/src/app/cron/index.test.tsx b/apps/desktop/src/app/cron/index.test.tsx index cb97af3d79..ea3aa4a3dc 100644 --- a/apps/desktop/src/app/cron/index.test.tsx +++ b/apps/desktop/src/app/cron/index.test.tsx @@ -111,7 +111,12 @@ describe('CronView queued trigger feedback (#70826)', () => { // The backend materializes the run the trigger produced. const observedAt = Date.now() / 1000 getCronJobRuns.mockResolvedValue([ - { id: 'cron_job-1_20261006_120001', last_active: observedAt, started_at: observedAt, title: 'Status report run' } as SessionInfo + { + id: 'cron_job-1_20261006_120001', + last_active: observedAt, + started_at: observedAt, + title: 'Status report run' + } as SessionInfo ]) await act(async () => { releaseTrigger(job) @@ -139,7 +144,12 @@ describe('CronView queued trigger feedback (#70826)', () => { const callsBeforeChange = getCronJobRuns.mock.calls.length const observedAt = Date.now() / 1000 getCronJobRuns.mockResolvedValue([ - { id: 'cron_job-1_20261006_120002', last_active: observedAt, started_at: observedAt, title: 'Status report run' } as SessionInfo + { + id: 'cron_job-1_20261006_120002', + last_active: observedAt, + started_at: observedAt, + title: 'Status report run' + } as SessionInfo ]) await act(async () => { notifyCronChanged() diff --git a/apps/desktop/src/app/cron/index.tsx b/apps/desktop/src/app/cron/index.tsx index 7dc53e0469..c89001ef8e 100644 --- a/apps/desktop/src/app/cron/index.tsx +++ b/apps/desktop/src/app/cron/index.tsx @@ -1045,7 +1045,11 @@ function CronJobRuns({ void load() } }, - pending ? PENDING_RUNS_POLL_INTERVAL_MS : changeEventsAvailable ? RUNS_BACKSTOP_INTERVAL_MS : RUNS_POLL_INTERVAL_MS + pending + ? PENDING_RUNS_POLL_INTERVAL_MS + : changeEventsAvailable + ? RUNS_BACKSTOP_INTERVAL_MS + : RUNS_POLL_INTERVAL_MS ) const onVisible = () => { diff --git a/apps/desktop/src/app/open-session.test.ts b/apps/desktop/src/app/open-session.test.ts index f829c075ea..0396d9d2ea 100644 --- a/apps/desktop/src/app/open-session.test.ts +++ b/apps/desktop/src/app/open-session.test.ts @@ -282,7 +282,7 @@ describe('openSession', () => { openSessionFromPicker('s2', navigate) expect($sessionResumeRequest.get()?.sessionId).toBe('s2') - expect(($sessionResumeRequest.get()?.sequence ?? 0)).toBeGreaterThan(1) + expect($sessionResumeRequest.get()?.sequence ?? 0).toBeGreaterThan(1) }) it('window pops out when the bridge supports it', () => { diff --git a/apps/desktop/src/app/settings/constants.ts b/apps/desktop/src/app/settings/constants.ts index 2042a9d236..da7e73d1b2 100644 --- a/apps/desktop/src/app/settings/constants.ts +++ b/apps/desktop/src/app/settings/constants.ts @@ -361,7 +361,12 @@ export const ENUM_OPTIONS: Record = { ], // STT model lists mirror STT_MODEL_CATALOG in tools/transcription_common.py. 'stt.openai.model': ['whisper-1', 'gpt-4o-mini-transcribe', 'gpt-4o-transcribe', 'gpt-transcribe'], - 'stt.openai.streaming_model': ['gpt-live-transcribe', 'gpt-transcribe', 'gpt-4o-transcribe', 'gpt-4o-mini-transcribe'], + 'stt.openai.streaming_model': [ + 'gpt-live-transcribe', + 'gpt-transcribe', + 'gpt-4o-transcribe', + 'gpt-4o-mini-transcribe' + ], 'stt.groq.model': ['whisper-large-v3-turbo', 'whisper-large-v3'], 'stt.mistral.model': ['voxtral-mini-latest', 'voxtral-mini-2602'], 'stt.xai.model': ['grok-voice-transcribe-2.0', 'grok-voice-transcribe-1.0'], diff --git a/apps/desktop/src/app/settings/uninstall-section.test.tsx b/apps/desktop/src/app/settings/uninstall-section.test.tsx index 7e2759bbdc..32dcfe5098 100644 --- a/apps/desktop/src/app/settings/uninstall-section.test.tsx +++ b/apps/desktop/src/app/settings/uninstall-section.test.tsx @@ -18,7 +18,9 @@ function Surface(): React.JSX.Element { function summary(allowed: boolean): DesktopUninstallSummary { return { code_removal_allowed: allowed, - native_removal_instructions: allowed ? null : 'Quit the app and drag Hermes Agent.app from /Applications to the Trash.', + native_removal_instructions: allowed + ? null + : 'Quit the app and drag Hermes Agent.app from /Applications to the Trash.', hermes_home: '/test/home', agent_installed: true, gui_installed: true, diff --git a/apps/desktop/src/app/shell/butterbar.tsx b/apps/desktop/src/app/shell/butterbar.tsx index ed9784aec0..08176b56a7 100644 --- a/apps/desktop/src/app/shell/butterbar.tsx +++ b/apps/desktop/src/app/shell/butterbar.tsx @@ -17,7 +17,10 @@ export function Butterbar() { const items = useStore($butterbarItems) const [activeId, setActiveId] = useState(null) const [paused, setPaused] = useState(false) - const index = Math.max(0, items.findIndex(item => item.id === activeId)) + const index = Math.max( + 0, + items.findIndex(item => item.id === activeId) + ) const item = items[index] const multiple = items.length > 1 diff --git a/apps/desktop/src/components/assistant-ui/catalog-advanced-dialog.tsx b/apps/desktop/src/components/assistant-ui/catalog-advanced-dialog.tsx index 10f3c6371f..a368b8610b 100644 --- a/apps/desktop/src/components/assistant-ui/catalog-advanced-dialog.tsx +++ b/apps/desktop/src/components/assistant-ui/catalog-advanced-dialog.tsx @@ -110,7 +110,9 @@ function CatalogAdvancedForm({ entry, fields, kind, onCancel, onInstall }: Catal const [agentHalf, setAgentHalf] = useState(true) const [desktopHalf, setDesktopHalf] = useState(entry.hasDesktopHalf) // Empty when the chat's home is no named profile: the install then goes to the chat's own home. - const [targetProfile, setTargetProfile] = useState(entry.targetProfile ? normalizeProfileKey(entry.targetProfile) : '') + const [targetProfile, setTargetProfile] = useState( + entry.targetProfile ? normalizeProfileKey(entry.targetProfile) : '' + ) const [enable, setEnable] = useState(true) const [force, setForce] = useState(false) const [pin, setPin] = useState(entry.sha ?? '') diff --git a/apps/desktop/src/components/assistant-ui/catalog-install-tool.tsx b/apps/desktop/src/components/assistant-ui/catalog-install-tool.tsx index 26a4a4ae0e..a5f8ef8dfd 100644 --- a/apps/desktop/src/components/assistant-ui/catalog-install-tool.tsx +++ b/apps/desktop/src/components/assistant-ui/catalog-install-tool.tsx @@ -68,7 +68,9 @@ function retriedRow(target: CatalogTarget, result: AgentPluginInstallResult): Ca alreadyInstalled: false, enabled: result.enabled ?? null, missingEnv: result.missingEnv ?? [], - serverErrors: servers.filter(server => !server.connected).map(({ error, name }) => ({ error: error ?? '', name })), + serverErrors: servers + .filter(server => !server.connected) + .map(({ error, name }) => ({ error: error ?? '', name })), skill: result.skillIds?.[0] ?? null }, detail: '', @@ -192,7 +194,8 @@ export function CatalogRow({ request, target }: CatalogRowProps) { const approved = catalog.approved const result = await installAgentPlugin( - (method, params, timeoutMs) => requestGatewayForAgent(owner.connectionId, owner.profile, method, params, timeoutMs), + (method, params, timeoutMs) => + requestGatewayForAgent(owner.connectionId, owner.profile, method, params, timeoutMs), { catalogName: target.name, enable: approved?.enable, diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/tree-split.test.tsx b/apps/desktop/src/components/pane-shell/tree/renderer/tree-split.test.tsx index 8a714b2959..1283e19076 100644 --- a/apps/desktop/src/components/pane-shell/tree/renderer/tree-split.test.tsx +++ b/apps/desktop/src/components/pane-shell/tree/renderer/tree-split.test.tsx @@ -39,8 +39,7 @@ vi.mock('./tree-node', () => ({ // The sash math only needs the zone element it measures by // [data-tree-group=""]; the real TreeNode pulls in the whole zone chrome // (context menus, i18n, drag sessions) that these tests never touch. - TreeNode: ({ node }: { node: LayoutNode }) => - node.type === 'group' ?
: null + TreeNode: ({ node }: { node: LayoutNode }) => (node.type === 'group' ?
: null) })) const SPLIT_ID = 'spl-root' @@ -241,5 +240,4 @@ describe('TreeSplit sash drag cleanup', () => { flushRaf() expect(kidA.getAttribute('style')).toBe(styleA) }) - }) diff --git a/apps/desktop/src/i18n/ar_common.ts b/apps/desktop/src/i18n/ar_common.ts index c694180fb1..05371737e4 100644 --- a/apps/desktop/src/i18n/ar_common.ts +++ b/apps/desktop/src/i18n/ar_common.ts @@ -12,7 +12,8 @@ export const arCommon = { collectedModels: 'مسارات النماذج وإجمالي الرموز', collectedNames: 'أسماء الأدوات والأوامر وعناصر الفهرس المدمجة', collectedMilestones: 'أعداد الإعداد ضمن فئات', - collectedReliability: 'نتائج التحديث والتثبيت ومدتهما (مع سبب من قائمة ثابتة والمرحلة عند الفشل، بما في ذلك التثبيت الجديد الذي يُسجَّل على هذا الجهاز ولا يُحتسب إلا بعد موافقتك)، والأعطال، وسرعة البدء والرد، وحالة منصات المراسلة', + collectedReliability: + 'نتائج التحديث والتثبيت ومدتهما (مع سبب من قائمة ثابتة والمرحلة عند الفشل، بما في ذلك التثبيت الجديد الذي يُسجَّل على هذا الجهاز ولا يُحتسب إلا بعد موافقتك)، والأعطال، وسرعة البدء والرد، وحالة منصات المراسلة', collectedUsage: 'كيفية استخدام Hermes: دقة الوكيل وكفاءته (نجاح التعديلات، الحلقات، التعافي من الأخطاء، الرموز واستدعاءات الأدوات لكل مهمة، انقطاعات الذاكرة المؤقتة)، وقت النشاط لكل واجهة ووضع في تطبيق سطح المكتب، أقسام التطبيق وإجراءاته وإعداداته التي تُستخدم أو تُغلق بسرعة أو تُعطَّل، ونتائج إعداد المزوّدين', collectedMachine: diff --git a/apps/desktop/src/i18n/en_shared_metrics.ts b/apps/desktop/src/i18n/en_shared_metrics.ts index 88d1914bac..b733416f03 100644 --- a/apps/desktop/src/i18n/en_shared_metrics.ts +++ b/apps/desktop/src/i18n/en_shared_metrics.ts @@ -11,7 +11,8 @@ export const enSharedMetrics: Translations['sharedMetrics'] = { collectedModels: 'Model routes and token totals', collectedNames: 'Built-in tool, command and catalog names', collectedMilestones: 'Bucketed setup counts', - collectedReliability: 'Update and install results and timing (with a fixed-list reason and the stage when one fails, including a fresh install recorded on this machine and counted only once you opt in), crashes, startup and reply speed, messaging-platform health', + collectedReliability: + 'Update and install results and timing (with a fixed-list reason and the stage when one fails, including a fresh install recorded on this machine and counted only once you opt in), crashes, startup and reply speed, messaging-platform health', collectedUsage: 'How Hermes gets used: agent accuracy and efficiency (edit matches, loops, recoveries, tokens and tool calls per task, cache breaks), active time per surface and Desktop mode, which app areas, actions and settings are used, closed quickly or switched off, and provider setup outcomes', collectedMachine: diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index ac21c12e36..aa40ec8b66 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -41,7 +41,8 @@ export const zhHant = defineLocale({ collectedModels: '模型路由和 token 總量', collectedNames: '內建工具、指令和目錄項名稱', collectedMilestones: '分組的設定計數', - collectedReliability: '更新和安裝的結果與耗時(失敗時包括來自固定清單的原因和所在階段;全新安裝記錄在本機,僅在你同意後計入)、當機、啟動與回覆速度、訊息平台狀態', + collectedReliability: + '更新和安裝的結果與耗時(失敗時包括來自固定清單的原因和所在階段;全新安裝記錄在本機,僅在你同意後計入)、當機、啟動與回覆速度、訊息平台狀態', collectedUsage: 'Hermes 的使用方式:代理的準確度與效率(編輯是否成功、迴圈、錯誤後的恢復、每個任務的 token 與工具呼叫數、快取中斷),各介面與 Desktop 模式的活躍時間,哪些應用程式區域、操作與設定被使用、很快關閉或被關閉,以及供應商設定的結果', collectedMachine: diff --git a/apps/desktop/src/i18n/zh_shared_metrics.ts b/apps/desktop/src/i18n/zh_shared_metrics.ts index 1d9692785e..94201c2599 100644 --- a/apps/desktop/src/i18n/zh_shared_metrics.ts +++ b/apps/desktop/src/i18n/zh_shared_metrics.ts @@ -9,7 +9,8 @@ export const zhSharedMetrics = { collectedModels: '模型路由和 token 总量', collectedNames: '内置工具、命令和目录项名称', collectedMilestones: '分桶的设置计数', - collectedReliability: '更新和安装的结果与耗时(失败时包括来自固定列表的原因和所在阶段;全新安装记录在本机,仅在你同意后计入)、崩溃、启动与回复速度、消息平台状态', + collectedReliability: + '更新和安装的结果与耗时(失败时包括来自固定列表的原因和所在阶段;全新安装记录在本机,仅在你同意后计入)、崩溃、启动与回复速度、消息平台状态', collectedUsage: 'Hermes 的使用方式:代理的准确度与效率(编辑是否成功、循环、错误后的恢复、每个任务的 token 与工具调用数、缓存中断),各界面与 Desktop 模式的活跃时间,哪些应用区域、操作与设置被使用、很快关闭或被关闭,以及提供商设置的结果', collectedMachine: diff --git a/apps/desktop/src/store/butterbar.ts b/apps/desktop/src/store/butterbar.ts index c26586f78a..b2f1f724d0 100644 --- a/apps/desktop/src/store/butterbar.ts +++ b/apps/desktop/src/store/butterbar.ts @@ -38,12 +38,14 @@ export const $butterbarDismissed = persistentAtom( Codecs.json(sanitizeKeys) ) -export const $butterbarItems = computed([$registered, $closedThisRun, $butterbarDismissed], (items, closed, dismissed) => - items - .filter(item => !closed.includes(item.id) && !(item.persistKey && dismissed.includes(item.persistKey))) - .map((item, order) => ({ item, order })) - .sort((a, b) => (b.item.priority ?? 0) - (a.item.priority ?? 0) || a.order - b.order) - .map(({ item }) => item) +export const $butterbarItems = computed( + [$registered, $closedThisRun, $butterbarDismissed], + (items, closed, dismissed) => + items + .filter(item => !closed.includes(item.id) && !(item.persistKey && dismissed.includes(item.persistKey))) + .map((item, order) => ({ item, order })) + .sort((a, b) => (b.item.priority ?? 0) - (a.item.priority ?? 0) || a.order - b.order) + .map(({ item }) => item) ) /** Put a notice in the bar from anywhere. Re-registering an id replaces it in diff --git a/apps/desktop/src/store/free-tier-challenge.test.ts b/apps/desktop/src/store/free-tier-challenge.test.ts index f8ea9a8192..d5d30a431b 100644 --- a/apps/desktop/src/store/free-tier-challenge.test.ts +++ b/apps/desktop/src/store/free-tier-challenge.test.ts @@ -26,7 +26,11 @@ describe('runFreeTierChallenge', () => { installBridge(run) await expect(runFreeTierChallenge({ ...challenge, attempt: 3 }, report)).resolves.toBe('error') expect(run).toHaveBeenCalledWith(expect.objectContaining({ attempt: 3 })) - expect(report).toHaveBeenCalledWith('free_tier.challenge_result', { url: challenge.url, attempt: 3, outcome: 'error' }) + expect(report).toHaveBeenCalledWith('free_tier.challenge_result', { + url: challenge.url, + attempt: 3, + outcome: 'error' + }) }) it('hands a browser challenge to the main process', async () => { const run = vi.fn().mockResolvedValue('done') diff --git a/apps/desktop/src/store/free-tier-challenge.ts b/apps/desktop/src/store/free-tier-challenge.ts index 7781c32fdc..51972d4f74 100644 --- a/apps/desktop/src/store/free-tier-challenge.ts +++ b/apps/desktop/src/store/free-tier-challenge.ts @@ -13,7 +13,10 @@ import type { FreeTierChallengePayload, FreeTierChallengeResultParams } from '@h export type FreeTierChallengeOutcome = FreeTierChallengeResultParams['outcome'] /** Sends ``free_tier.challenge_result`` to the backend that announced the challenge. */ -export type ChallengeRequester = (method: 'free_tier.challenge_result', params: Record) => Promise +export type ChallengeRequester = ( + method: 'free_tier.challenge_result', + params: Record +) => Promise const inFlight = new Map>() diff --git a/apps/desktop/src/store/projects.ts b/apps/desktop/src/store/projects.ts index cc02e9602f..4bbc5e3ade 100644 --- a/apps/desktop/src/store/projects.ts +++ b/apps/desktop/src/store/projects.ts @@ -1655,7 +1655,12 @@ export async function pickProjectFolders(): Promise { // project already has — or repeats within the pick itself — are skipped, so // a multi-select never writes duplicate rows (#68741). export async function addProjectFolders(id: string, paths: string[]): Promise { - const existing = new Set($projects.get().find(proj => proj.id === id)?.folders?.map(folder => folder.path) ?? []) + const existing = new Set( + $projects + .get() + .find(proj => proj.id === id) + ?.folders?.map(folder => folder.path) ?? [] + ) for (const path of new Set(paths.map(path => path.trim()).filter(Boolean))) { if (existing.has(path)) { diff --git a/apps/desktop/src/store/session-states.test.ts b/apps/desktop/src/store/session-states.test.ts index 5608b37844..0a1fd46096 100644 --- a/apps/desktop/src/store/session-states.test.ts +++ b/apps/desktop/src/store/session-states.test.ts @@ -1321,7 +1321,9 @@ describe('$focusedStoredSessionId in Bot Mode (#96062)', () => { id: 'repo', label: 'repo', path: null, - groups: [{ id: 'lane', label: 'lane', path: null, sessions: [{ cwd: '/repo-tree', id: 'tree-5' } as never] }], + groups: [ + { id: 'lane', label: 'lane', path: null, sessions: [{ cwd: '/repo-tree', id: 'tree-5' } as never] } + ], sessionCount: 1 } ], @@ -1361,7 +1363,12 @@ describe('$focusedStoredSessionId in Bot Mode (#96062)', () => { label: 'repo', path: null, groups: [ - { id: 'lane', label: 'lane', path: null, sessions: [{ cwd: '/repo-deep-primary', id: 'deep-primary' } as never] } + { + id: 'lane', + label: 'lane', + path: null, + sessions: [{ cwd: '/repo-deep-primary', id: 'deep-primary' } as never] + } ], sessionCount: 1 } diff --git a/web/src/pages/SessionsPage.tsx b/web/src/pages/SessionsPage.tsx index bb208fdb5c..b391f1ccb0 100644 --- a/web/src/pages/SessionsPage.tsx +++ b/web/src/pages/SessionsPage.tsx @@ -49,8 +49,8 @@ import { AUTOMATION_SESSION_SOURCES, NO_MATCHING_SESSION_SOURCE, SOURCE_CONFIG, - SessionFilterCategory, - SourceSelectionsByCategory, + type SessionFilterCategory, + type SourceSelectionsByCategory, isAutomationSource, sourceBelongsToCategory, sourceLabel,