Merge branch 'openchamber:main' into github-usage-rework
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openchamber/web",
|
||||
"version": "1.19.0",
|
||||
"version": "1.20.0",
|
||||
"private": false,
|
||||
"type": "module",
|
||||
"main": "./server/index.js",
|
||||
@@ -13,8 +13,8 @@
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "bun run build:watch",
|
||||
"dev:server": "bun server/index.js --port ${OPENCHAMBER_PORT:-3001}",
|
||||
"dev:server:watch": "nodemon --watch server --ext js --exec \"bun server/index.js --port ${OPENCHAMBER_PORT:-3001}\"",
|
||||
"dev:server": "OPENCHAMBER_RELAY_HOST=${OPENCHAMBER_RELAY_HOST:-off} bun server/index.js --port ${OPENCHAMBER_PORT:-3001}",
|
||||
"dev:server:watch": "OPENCHAMBER_RELAY_HOST=${OPENCHAMBER_RELAY_HOST:-off} nodemon --watch server --ext js --exec \"bun server/index.js --port ${OPENCHAMBER_PORT:-3001}\"",
|
||||
"build": "vite build",
|
||||
"build:watch": "vite build --watch",
|
||||
"type-check": "tsc --noEmit",
|
||||
@@ -25,7 +25,7 @@
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.1.0",
|
||||
"@octokit/rest": "^22.0.1",
|
||||
"@opencode-ai/sdk": "1.18.18",
|
||||
"@opencode-ai/sdk": "1.18.21",
|
||||
"@simplewebauthn/server": "13.3.1",
|
||||
"bun-pty": "^0.4.5",
|
||||
"compression": "^1.8.1",
|
||||
|
||||
@@ -1291,6 +1291,7 @@ const resolveMemoryProjectId = createMemoryProjectResolver({
|
||||
return sanitizeProjects(settings?.projects || []).map((project) => project.path);
|
||||
},
|
||||
resolvePrimaryWorktreeRoot,
|
||||
managedProjectRoots: [path.join(OPENCHAMBER_USER_CONFIG_ROOT, 'chats')],
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -1814,6 +1815,14 @@ async function main(options = {}) {
|
||||
fs,
|
||||
process,
|
||||
}),
|
||||
// Dev/debug instances share the data dir (and thus the relay identity) with
|
||||
// the production instance, so they must not host the relay on their own —
|
||||
// paired devices would land on them. OPENCHAMBER_RELAY_HOST=off disables
|
||||
// passive hosting explicitly (dev scripts set it); the Electron dev shell is
|
||||
// covered via OPENCHAMBER_ELECTRON_DEV. OPENCHAMBER_RELAY_HOST=on overrides
|
||||
// both. Explicit enable/pairing on the instance still hosts regardless.
|
||||
allowPassiveHost: process.env.OPENCHAMBER_RELAY_HOST === 'on'
|
||||
|| (process.env.OPENCHAMBER_RELAY_HOST !== 'off' && process.env.OPENCHAMBER_ELECTRON_DEV !== '1'),
|
||||
// Relay demand = any paired device or pending pairing session that uses the
|
||||
// relay transport. Drives the auto on/off lifecycle.
|
||||
hasRelayDemand: async () => {
|
||||
|
||||
@@ -24,7 +24,8 @@ const normalize = (value) => {
|
||||
};
|
||||
|
||||
export const createMemoryProjectResolver = (dependencies) => {
|
||||
const { listProjectPaths, resolvePrimaryWorktreeRoot } = dependencies;
|
||||
const { listProjectPaths, resolvePrimaryWorktreeRoot, managedProjectRoots = [] } = dependencies;
|
||||
const managedRoots = managedProjectRoots.map(normalize).filter(Boolean);
|
||||
|
||||
return async (directory) => {
|
||||
const resolved = normalize(directory);
|
||||
@@ -32,6 +33,14 @@ export const createMemoryProjectResolver = (dependencies) => {
|
||||
return '';
|
||||
}
|
||||
|
||||
const managedRoot = managedRoots.find((root) => {
|
||||
const relative = path.relative(root, resolved);
|
||||
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
||||
});
|
||||
if (managedRoot) {
|
||||
return createProjectIdFromPath(managedRoot);
|
||||
}
|
||||
|
||||
let configured = [];
|
||||
try {
|
||||
configured = ((await listProjectPaths()) || []).map(normalize).filter(Boolean);
|
||||
|
||||
@@ -51,6 +51,15 @@ describe('resolving a session directory to its project', () => {
|
||||
expect(await resolve('/tmp/loose')).toBe(createProjectIdFromPath('/tmp/loose'));
|
||||
});
|
||||
|
||||
test('managed chat session directories share the Chats root store', async () => {
|
||||
const chatsRoot = '/Users/x/.config/openchamber/chats';
|
||||
const resolve = createResolver({ managedProjectRoots: [chatsRoot] });
|
||||
|
||||
expect(await resolve(`${chatsRoot}/2026-08-21/session-a`)).toBe(createProjectIdFromPath(chatsRoot));
|
||||
expect(await resolve(`${chatsRoot}/2026-08-21/session-b`)).toBe(createProjectIdFromPath(chatsRoot));
|
||||
expect(await resolve('/Users/x/.config/openchamber/chats-other/session-a')).not.toBe(createProjectIdFromPath(chatsRoot));
|
||||
});
|
||||
|
||||
test('no directory resolves to nothing rather than to some default project', async () => {
|
||||
const resolve = createResolver();
|
||||
|
||||
|
||||
@@ -236,8 +236,13 @@ export const createAgentMemoryRuntime = (deps) => {
|
||||
const writeJsonAtomic = async (filePath, value) => {
|
||||
const temporaryPath = `${filePath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
await fsPromises.mkdir(path.dirname(filePath), { recursive: true });
|
||||
await fsPromises.writeFile(temporaryPath, JSON.stringify(value, null, 2), 'utf8');
|
||||
await fsPromises.rename(temporaryPath, filePath);
|
||||
try {
|
||||
await fsPromises.writeFile(temporaryPath, JSON.stringify(value, null, 2), 'utf8');
|
||||
await fsPromises.rename(temporaryPath, filePath);
|
||||
} catch (error) {
|
||||
await fsPromises.rm(temporaryPath, { force: true }).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const withWriteLock = async (key, mutate) => {
|
||||
|
||||
@@ -262,14 +262,15 @@ export const createClientPairingRuntime = ({
|
||||
if (!constantTimeEqual(session.secretHash, hashSecret(normalizedSecret), crypto)) throw redeemError();
|
||||
|
||||
// The operator's typed pairing label is THIS server's name for the device
|
||||
// (shown in the device list). It wins over the device's self-reported
|
||||
// label; fall back to that only when no pairing label was set.
|
||||
const label = normalizeOptionalString(session.label)
|
||||
|| normalizeOptionalString(clientLabel)
|
||||
|| normalizeOptionalString(deviceName)
|
||||
|| 'Remote client';
|
||||
// (shown in the device list) and wins outright. The device's self-reported
|
||||
// label is only a fallback: on a re-pair with the same dedupeKey,
|
||||
// createClient keeps the replaced record's label over it, so a rescan
|
||||
// without a typed name does not reset the device to the app default.
|
||||
const result = await remoteClientAuthRuntime.createClient({
|
||||
label,
|
||||
label: normalizeOptionalString(session.label),
|
||||
fallbackLabel: normalizeOptionalString(clientLabel)
|
||||
|| normalizeOptionalString(deviceName)
|
||||
|| 'Remote client',
|
||||
clientKind: normalizedKind,
|
||||
dedupeKey: normalizeOptionalString(dedupeKey) || `pairing:${session.id}`,
|
||||
authMethod: 'pairing',
|
||||
|
||||
@@ -13,7 +13,7 @@ const makeRuntime = async (options = {}) => {
|
||||
createClient: vi.fn(async (input) => {
|
||||
const client = {
|
||||
id: `client-${createdClients.length + 1}`,
|
||||
label: input.label,
|
||||
label: input.label ?? input.fallbackLabel,
|
||||
clientKind: input.clientKind,
|
||||
authMethod: input.authMethod,
|
||||
pairingId: input.pairingId,
|
||||
@@ -61,6 +61,10 @@ describe('client auth pairing runtime', () => {
|
||||
pairingId: created.pairing.id,
|
||||
clientKind: 'mobile',
|
||||
dedupeKey: 'device-key',
|
||||
// No operator-typed pairing label: the app-reported name is only a
|
||||
// fallback so a re-pair keeps the existing device record's label.
|
||||
label: null,
|
||||
fallbackLabel: 'Iryna iPhone',
|
||||
}));
|
||||
|
||||
await expect(runtime.redeemPairingSession({
|
||||
|
||||
@@ -157,6 +157,7 @@ export const createRemoteClientAuthRuntime = ({ fsPromises, path, crypto, storeP
|
||||
|
||||
const createClient = async ({
|
||||
label,
|
||||
fallbackLabel,
|
||||
expiresAt,
|
||||
clientKind,
|
||||
dedupeKey,
|
||||
@@ -172,9 +173,16 @@ export const createRemoteClientAuthRuntime = ({ fsPromises, path, crypto, storeP
|
||||
const store = await readStore();
|
||||
const normalizedDedupeKey = normalizeOptionalString(dedupeKey);
|
||||
const token = generateToken();
|
||||
// A dedupe-keyed mint REPLACES the previous record for the same device,
|
||||
// so an operator-visible name must survive the replacement: an explicit
|
||||
// label wins, otherwise the replaced record's label is kept, and only a
|
||||
// first-ever mint falls back to the client-reported default.
|
||||
const existing = normalizedDedupeKey
|
||||
? store.clients.find((entry) => entry.dedupeKey === normalizedDedupeKey)
|
||||
: null;
|
||||
const client = {
|
||||
id: generateId(),
|
||||
label: normalizeLabel(label),
|
||||
label: normalizeLabel(normalizeOptionalString(label) || existing?.label || fallbackLabel),
|
||||
tokenHash: hashToken(token),
|
||||
createdAt: nowIso(),
|
||||
lastUsedAt: null,
|
||||
|
||||
@@ -83,6 +83,23 @@ describe('remote client auth runtime', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the replaced record label on a dedupe re-mint without an explicit label', async () => {
|
||||
const { dir, runtime } = await createRuntime();
|
||||
try {
|
||||
await runtime.createClient({ label: 'Iryna iPhone', dedupeKey: 'mobile:device-1', fallbackLabel: 'OpenChamber Mobile' });
|
||||
const remint = await runtime.createClient({ dedupeKey: 'mobile:device-1', fallbackLabel: 'OpenChamber Mobile' });
|
||||
expect(remint.client.label).toBe('Iryna iPhone');
|
||||
|
||||
const renamed = await runtime.createClient({ label: 'Work phone', dedupeKey: 'mobile:device-1', fallbackLabel: 'OpenChamber Mobile' });
|
||||
expect(renamed.client.label).toBe('Work phone');
|
||||
|
||||
const fresh = await runtime.createClient({ dedupeKey: 'mobile:device-2', fallbackLabel: 'OpenChamber Mobile' });
|
||||
expect(fresh.client.label).toBe('OpenChamber Mobile');
|
||||
} finally {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the token store private on disk', async () => {
|
||||
const { dir, runtime } = await createRuntime();
|
||||
try {
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
# Dictation module
|
||||
|
||||
Server-authoritative streaming speech-to-text for the chat composer, plus
|
||||
local text-to-speech. The client streams 16 kHz mono PCM16 chunks (base64)
|
||||
over a WebSocket; the server runs the transcription and streams live partial
|
||||
transcripts back.
|
||||
Server-authoritative speech-to-text for the chat composer, plus local
|
||||
text-to-speech. The client streams 16 kHz mono PCM16 chunks (base64) over a
|
||||
WebSocket while the user speaks; the server buffers them and transcribes each
|
||||
segment exactly once, when the segment is committed.
|
||||
|
||||
Transcription is deliberately not incremental. Parakeet is an offline model
|
||||
trained on whole utterances, so re-decoding the growing buffer to animate a
|
||||
live transcript costs O(n^2) work for a result the final decode replaces. The
|
||||
composer shows no text while recording and inserts the full transcript on
|
||||
stop.
|
||||
|
||||
Local TTS (Kokoro via sherpa-onnx OfflineTts) runs in the same worker process
|
||||
and is exposed as `POST /api/dictation/tts/speak` (JSON `{text, speakerId?,
|
||||
@@ -21,9 +27,9 @@ same status/download/delete routes.
|
||||
Created from the startup pipeline (`startup-pipeline-runtime.js`) before
|
||||
the generic OpenCode proxy so routes are not shadowed.
|
||||
- `stream-manager.js` — `DictationStreamManager`, one per WS connection.
|
||||
Chunk reordering by `seq` + ack, resampling to the provider rate,
|
||||
auto-commit every ~15 s of audio, silence suppression by PCM peak,
|
||||
partial-transcript concatenation, adaptive finalization timeout.
|
||||
Chunk reordering by `seq` + ack, resampling to the provider rate, segment
|
||||
splitting, silence suppression by PCM peak, partial-transcript
|
||||
concatenation, adaptive finalization timeout.
|
||||
- `service.js` — provider resolution and readiness. Providers:
|
||||
- `local` (default): sherpa-onnx Parakeet TDT in a forked worker process.
|
||||
Models auto-download in the background on first use; while missing, the
|
||||
@@ -33,7 +39,7 @@ same status/download/delete routes.
|
||||
OpenAI-compatible `/v1/audio/transcriptions` endpoint
|
||||
(`openai-compatible-session.js`, reuses `../tts/stt.js`).
|
||||
- `local/` — worker process + client (IPC, idle shutdown TTL), sherpa
|
||||
recognizer engine and realtime session (throttled re-decode for partials),
|
||||
recognizer engine and segment session (one decode per committed segment),
|
||||
model catalog and downloader. The native `sherpa-onnx-node` addon is only
|
||||
ever loaded inside the worker process.
|
||||
- `audio.js` — PCM16 helpers: format parsing, peak, WAV wrapping, streaming
|
||||
@@ -53,9 +59,27 @@ Server → client: `ready`, `ack {ackSeq}`, `partial {text}`,
|
||||
`{ provider: 'local' | 'openai-compatible', language?, localModel?,
|
||||
openaiCompatible?: { baseUrl, model, apiKey } }`.
|
||||
|
||||
## Segmentation
|
||||
|
||||
A dictation is one segment unless it runs long. Past `segmentMinSeconds`
|
||||
(60 s) the manager commits on the first silent chunk, so cuts land at a pause
|
||||
rather than mid-word; `segmentMaxSeconds` (90 s) is a hard cap for speech with
|
||||
no pause in it. Client chunks are ~1 s, so "silent chunk" is roughly a second
|
||||
of silence.
|
||||
|
||||
The bounds exist because Parakeet is a full-attention conformer: decode cost
|
||||
and peak memory grow quadratically with segment length. Measured on Parakeet
|
||||
v3 int8 with 2 threads: 60 s took 2.1 s and +90 MB, 180 s took 9.3 s and
|
||||
+490 MB, 300 s took 21.3 s and +1.5 GB. Committed segments decode while the
|
||||
user is still speaking, so only the tail is left to transcribe on stop.
|
||||
|
||||
## Invariants
|
||||
|
||||
- Never load `sherpa-onnx-node` in the main server process.
|
||||
- Transcription happens on commit only; sessions never emit non-final
|
||||
transcripts. The `partial` messages a client receives are the concatenation
|
||||
of already-committed segments, and exist so a dictation that fails partway
|
||||
can be salvaged instead of losing minutes of speech.
|
||||
- The stream manager acks only the highest contiguous seq; the client is
|
||||
expected to retain unacked segments for retry/replay.
|
||||
- Silence-only segments (peak < 300) are cleared, never committed, so
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
/**
|
||||
* Sherpa-onnx offline recognizer engine (NeMo transducer / Parakeet) plus a
|
||||
* realtime streaming transcription session that re-decodes the accumulated
|
||||
* segment audio on a throttle to produce live partial transcripts.
|
||||
* segment transcription session that decodes each segment exactly once, when
|
||||
* the segment is committed.
|
||||
*
|
||||
* Parakeet is an offline model: it is trained to see a whole utterance at
|
||||
* once. Decoding the accumulated audio repeatedly to animate a live transcript
|
||||
* costs O(n^2) work for a result the final decode throws away, so this session
|
||||
* only decodes on commit.
|
||||
*
|
||||
* Runs inside the dictation worker process only — never load the native
|
||||
* addon in the main server process.
|
||||
@@ -147,31 +152,26 @@ export class SherpaOfflineRecognizerEngine {
|
||||
}
|
||||
|
||||
/**
|
||||
* Streaming transcription session backed by the offline recognizer.
|
||||
* Accumulates the current segment's PCM and re-decodes it at most every
|
||||
* `minDecodeIntervalMs` to emit non-final partial transcripts; `commit()`
|
||||
* finalizes the segment and starts a new one.
|
||||
* Segment transcription session backed by the offline recognizer.
|
||||
* Accumulates the current segment's PCM and decodes it once in `commit()`,
|
||||
* which emits the segment's final transcript and starts a new segment.
|
||||
*
|
||||
* Implements the StreamingTranscriptionSession contract used by
|
||||
* DictationStreamManager.
|
||||
* DictationStreamManager. It never emits non-final transcripts: the manager's
|
||||
* live `partial` messages are the concatenation of already-committed segments.
|
||||
*/
|
||||
export class SherpaRealtimeTranscriptionSession extends EventEmitter {
|
||||
export class SherpaSegmentTranscriptionSession extends EventEmitter {
|
||||
/**
|
||||
* @param {{ engine: SherpaOfflineRecognizerEngine, minDecodeIntervalMs?: number }} params
|
||||
* @param {{ engine: SherpaOfflineRecognizerEngine }} params
|
||||
*/
|
||||
constructor({ engine, minDecodeIntervalMs }) {
|
||||
constructor({ engine }) {
|
||||
super();
|
||||
this.engine = engine;
|
||||
this.requiredSampleRate = engine.sampleRate;
|
||||
this.minDecodeIntervalMs = minDecodeIntervalMs ?? 350;
|
||||
this.connected = false;
|
||||
this.currentSegmentId = null;
|
||||
this.previousSegmentId = null;
|
||||
this.lastPartialText = '';
|
||||
this.pcm16 = Buffer.alloc(0);
|
||||
this.lastDecodeAt = 0;
|
||||
this.decoding = false;
|
||||
this.pendingDecode = false;
|
||||
}
|
||||
|
||||
async connect() {
|
||||
@@ -184,39 +184,38 @@ export class SherpaRealtimeTranscriptionSession extends EventEmitter {
|
||||
|
||||
appendPcm16(chunk) {
|
||||
if (!this.connected || !this.currentSegmentId) {
|
||||
this.emit('error', new Error('Sherpa realtime session not connected'));
|
||||
this.emit('error', new Error('Sherpa transcription session not connected'));
|
||||
return;
|
||||
}
|
||||
this.pcm16 = this.pcm16.length === 0 ? chunk : Buffer.concat([this.pcm16, chunk]);
|
||||
this.maybeDecode(false).catch((err) => {
|
||||
this.emit('error', err instanceof Error ? err : new Error(String(err)));
|
||||
});
|
||||
}
|
||||
|
||||
commit() {
|
||||
if (!this.connected || !this.currentSegmentId) {
|
||||
this.emit('error', new Error('Sherpa realtime session not connected'));
|
||||
this.emit('error', new Error('Sherpa transcription session not connected'));
|
||||
return;
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
await this.maybeDecode(true);
|
||||
const finalText = this.lastPartialText;
|
||||
const segmentId = this.currentSegmentId;
|
||||
const previousSegmentId = this.previousSegmentId;
|
||||
const segmentId = this.currentSegmentId;
|
||||
const previousSegmentId = this.previousSegmentId;
|
||||
const pcm16 = this.pcm16;
|
||||
|
||||
this.emit('committed', { segmentId, previousSegmentId });
|
||||
this.emit('transcript', { segmentId, transcript: finalText, isFinal: true });
|
||||
// Start the next segment before decoding: decoding blocks the worker for
|
||||
// seconds on long segments, and audio for the next one keeps arriving.
|
||||
this.previousSegmentId = segmentId;
|
||||
this.currentSegmentId = randomUUID();
|
||||
this.pcm16 = Buffer.alloc(0);
|
||||
|
||||
this.previousSegmentId = segmentId;
|
||||
this.currentSegmentId = randomUUID();
|
||||
this.lastPartialText = '';
|
||||
this.pcm16 = Buffer.alloc(0);
|
||||
} catch (err) {
|
||||
this.emit('error', err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
})();
|
||||
this.emit('committed', { segmentId, previousSegmentId });
|
||||
|
||||
let transcript;
|
||||
try {
|
||||
transcript = this.engine.decodePcm16(pcm16);
|
||||
} catch (err) {
|
||||
this.emit('error', err instanceof Error ? err : new Error(String(err)));
|
||||
return;
|
||||
}
|
||||
this.emit('transcript', { segmentId, transcript, isFinal: true });
|
||||
}
|
||||
|
||||
clear() {
|
||||
@@ -225,7 +224,6 @@ export class SherpaRealtimeTranscriptionSession extends EventEmitter {
|
||||
}
|
||||
this.pcm16 = Buffer.alloc(0);
|
||||
this.currentSegmentId = randomUUID();
|
||||
this.lastPartialText = '';
|
||||
}
|
||||
|
||||
close() {
|
||||
@@ -233,45 +231,4 @@ export class SherpaRealtimeTranscriptionSession extends EventEmitter {
|
||||
this.currentSegmentId = null;
|
||||
this.pcm16 = Buffer.alloc(0);
|
||||
}
|
||||
|
||||
async maybeDecode(force) {
|
||||
if (!this.connected || !this.currentSegmentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
if (!force && now - this.lastDecodeAt < this.minDecodeIntervalMs) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.decoding) {
|
||||
this.pendingDecode = true;
|
||||
return;
|
||||
}
|
||||
|
||||
this.decoding = true;
|
||||
try {
|
||||
const decodeStartedAt = Date.now();
|
||||
const text = this.engine.decodePcm16(this.pcm16);
|
||||
this.lastDecodeAt = Date.now();
|
||||
// Adaptive throttle: on slow hardware (or heavy models) re-decoding the
|
||||
// growing segment every 350ms would monopolize the worker. Space partial
|
||||
// decodes to ~1.5x the observed decode time.
|
||||
this.minDecodeIntervalMs = Math.max(350, (this.lastDecodeAt - decodeStartedAt) * 1.5);
|
||||
if (text !== this.lastPartialText) {
|
||||
this.lastPartialText = text;
|
||||
this.emit('transcript', {
|
||||
segmentId: this.currentSegmentId,
|
||||
transcript: text,
|
||||
isFinal: false,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
this.decoding = false;
|
||||
if (this.pendingDecode) {
|
||||
this.pendingDecode = false;
|
||||
await this.maybeDecode(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
import {
|
||||
SherpaOfflineRecognizerEngine,
|
||||
SherpaRealtimeTranscriptionSession,
|
||||
SherpaSegmentTranscriptionSession,
|
||||
} from './sherpa-recognizer.js';
|
||||
import { SherpaTtsEngine } from './sherpa-tts.js';
|
||||
import { getLocalSttModelDir, getLocalSttModelSpec } from './model-catalog.js';
|
||||
@@ -126,7 +126,7 @@ async function handleRequest(message) {
|
||||
case 'session.create': {
|
||||
cleanupSession(message.sessionId);
|
||||
const engine = getEngine(message.modelsDir, message.modelId);
|
||||
const session = new SherpaRealtimeTranscriptionSession({ engine });
|
||||
const session = new SherpaSegmentTranscriptionSession({ engine });
|
||||
session.on('committed', (payload) => {
|
||||
sendToParent({ type: 'session.committed', sessionId: message.sessionId, payload });
|
||||
});
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
* endpoints (faster-whisper, whisper.cpp, OpenAI, ...).
|
||||
*
|
||||
* The Whisper HTTP API cannot stream, so audio is buffered per segment and
|
||||
* transcribed on commit(). Live partials therefore only advance at segment
|
||||
* boundaries (the DictationStreamManager auto-commits every ~15s of speech).
|
||||
* transcribed on commit(). This matches how the local session behaves: the
|
||||
* DictationStreamManager splits long dictations at pauses, and everything
|
||||
* shorter is one request on stop.
|
||||
*
|
||||
* Implements the StreamingTranscriptionSession contract used by
|
||||
* DictationStreamManager.
|
||||
|
||||
@@ -7,23 +7,54 @@
|
||||
* Responsibilities:
|
||||
* - Reorders inbound chunks by `seq` and acks the highest contiguous seq.
|
||||
* - Resamples client PCM (16 kHz by default) to the provider's required rate.
|
||||
* - Auto-commits a segment every `autoCommitSeconds` of audio, but clears
|
||||
* silence-only segments instead of committing them.
|
||||
* - Segments long dictations at natural pauses: past `segmentMinSeconds` of
|
||||
* audio it commits on the first silent chunk, and `segmentMaxSeconds` is a
|
||||
* hard cap for speech with no pause in it. Silence-only segments are
|
||||
* cleared instead of committed.
|
||||
* - Concatenates per-segment transcripts into live partials and emits the
|
||||
* final text once every committed segment has a final transcript.
|
||||
* final text once every committed segment has a final transcript. The
|
||||
* manager counts the commits it issued rather than trusting the session's
|
||||
* echoed events, so a commit still in flight when the client finishes
|
||||
* cannot be silently dropped from the transcript.
|
||||
* - Applies an adaptive finalization timeout budget based on pending work.
|
||||
*/
|
||||
|
||||
import { Pcm16MonoResampler, parsePcmRateFromFormat, pcm16lePeakAbs } from './audio.js';
|
||||
|
||||
const DEFAULT_FINAL_TIMEOUT_MS = 10000;
|
||||
const DEFAULT_AUTO_COMMIT_SECONDS = 15;
|
||||
// Parakeet is a full-attention conformer: decode cost and peak memory grow
|
||||
// quadratically with segment length (measured: 60s -> 2.1s/+90MB,
|
||||
// 300s -> 21.3s/+1.5GB). Segmenting keeps a long dictation off that curve and
|
||||
// lets committed segments decode while the user is still speaking, so only the
|
||||
// tail is left to transcribe on stop. Typical dictations are shorter than the
|
||||
// minimum and are decoded as a single segment.
|
||||
const DEFAULT_SEGMENT_MIN_SECONDS = 60;
|
||||
const DEFAULT_SEGMENT_MAX_SECONDS = 90;
|
||||
const FINAL_TIMEOUT_MAX_MS = 5 * 60 * 1000;
|
||||
const FINAL_TIMEOUT_PER_PENDING_SEGMENT_MS = 15 * 1000;
|
||||
const FINAL_TIMEOUT_PER_PENDING_AUDIO_SECOND_MS = 1500;
|
||||
const FINAL_TIMEOUT_PER_MISSING_SEQ_MS = 250;
|
||||
const SILENCE_PEAK_THRESHOLD = 300;
|
||||
|
||||
const secondsToPcm16Bytes = (seconds, sampleRate) =>
|
||||
seconds > 0 ? Math.max(1, Math.round(seconds * sampleRate * 2)) : 0;
|
||||
|
||||
/**
|
||||
* Split the current segment once it is long enough to be worth decoding on its
|
||||
* own and the speaker has just gone quiet, or unconditionally at the hard cap.
|
||||
* Client chunks are ~1s, so a quiet chunk is roughly a second of silence — long
|
||||
* enough to be a sentence boundary rather than a gap between words.
|
||||
*/
|
||||
function shouldSplitSegment(state) {
|
||||
if (state.segmentMaxBytes > 0 && state.bytesSinceCommit >= state.segmentMaxBytes) {
|
||||
return true;
|
||||
}
|
||||
if (state.segmentMinBytes <= 0 || state.bytesSinceCommit < state.segmentMinBytes) {
|
||||
return false;
|
||||
}
|
||||
return state.lastChunkPeak < SILENCE_PEAK_THRESHOLD;
|
||||
}
|
||||
|
||||
export class DictationStreamManager {
|
||||
/**
|
||||
* @param {object} params
|
||||
@@ -33,13 +64,15 @@ export class DictationStreamManager {
|
||||
* The streaming transcription session contract:
|
||||
* { requiredSampleRate, appendPcm16(buf), commit(), clear(), close(), on(event, handler) }
|
||||
* @param {number} [params.finalTimeoutMs]
|
||||
* @param {number} [params.autoCommitSeconds]
|
||||
* @param {number} [params.segmentMinSeconds] audio before a pause may split a segment
|
||||
* @param {number} [params.segmentMaxSeconds] hard segment cap for pauseless speech
|
||||
*/
|
||||
constructor({ emit, createSttSession, finalTimeoutMs, autoCommitSeconds }) {
|
||||
constructor({ emit, createSttSession, finalTimeoutMs, segmentMinSeconds, segmentMaxSeconds }) {
|
||||
this.emit = emit;
|
||||
this.createSttSession = createSttSession;
|
||||
this.finalTimeoutMs = finalTimeoutMs ?? DEFAULT_FINAL_TIMEOUT_MS;
|
||||
this.autoCommitSeconds = autoCommitSeconds ?? DEFAULT_AUTO_COMMIT_SECONDS;
|
||||
this.segmentMinSeconds = segmentMinSeconds ?? DEFAULT_SEGMENT_MIN_SECONDS;
|
||||
this.segmentMaxSeconds = segmentMaxSeconds ?? DEFAULT_SEGMENT_MAX_SECONDS;
|
||||
this.streams = new Map();
|
||||
}
|
||||
|
||||
@@ -87,13 +120,12 @@ export class DictationStreamManager {
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
// Segment accounting is reset where the commit is issued, not here: this
|
||||
// event arrives after an async hop, and zeroing the counters on arrival
|
||||
// would discard audio that came in meanwhile — up to and including
|
||||
// mistaking the tail of the dictation for silence and clearing it.
|
||||
state.committedSegmentIds.push(segmentId);
|
||||
state.bytesSinceCommit = 0;
|
||||
state.peakSinceCommit = 0;
|
||||
|
||||
if (state.finishRequested && state.awaitingFinalCommit) {
|
||||
state.awaitingFinalCommit = false;
|
||||
}
|
||||
state.pendingCommits = Math.max(0, state.pendingCommits - 1);
|
||||
|
||||
this.maybeFinalizeStream(dictationId);
|
||||
});
|
||||
@@ -108,10 +140,6 @@ export class DictationStreamManager {
|
||||
state.finalTranscriptSegmentIds.add(segmentId);
|
||||
}
|
||||
|
||||
if (state.finishRequested && state.awaitingFinalCommit && isFinal) {
|
||||
state.awaitingFinalCommit = false;
|
||||
}
|
||||
|
||||
const orderedIds = state.committedSegmentIds.includes(segmentId)
|
||||
? state.committedSegmentIds
|
||||
: [...state.committedSegmentIds, segmentId];
|
||||
@@ -143,16 +171,15 @@ export class DictationStreamManager {
|
||||
receivedChunks: new Map(),
|
||||
nextSeqToForward: 0,
|
||||
ackSeq: -1,
|
||||
autoCommitBytes:
|
||||
this.autoCommitSeconds > 0
|
||||
? Math.max(1, Math.round(this.autoCommitSeconds * stt.requiredSampleRate * 2))
|
||||
: 0,
|
||||
segmentMinBytes: secondsToPcm16Bytes(this.segmentMinSeconds, stt.requiredSampleRate),
|
||||
segmentMaxBytes: secondsToPcm16Bytes(this.segmentMaxSeconds, stt.requiredSampleRate),
|
||||
bytesSinceCommit: 0,
|
||||
peakSinceCommit: 0,
|
||||
lastChunkPeak: 0,
|
||||
committedSegmentIds: [],
|
||||
transcriptsBySegmentId: new Map(),
|
||||
finalTranscriptSegmentIds: new Set(),
|
||||
awaitingFinalCommit: false,
|
||||
pendingCommits: 0,
|
||||
finishRequested: false,
|
||||
finishSealed: false,
|
||||
finalSeq: null,
|
||||
@@ -203,7 +230,8 @@ export class DictationStreamManager {
|
||||
if (resampled.length > 0) {
|
||||
state.stt.appendPcm16(resampled);
|
||||
state.bytesSinceCommit += resampled.length;
|
||||
state.peakSinceCommit = Math.max(state.peakSinceCommit, pcm16lePeakAbs(resampled));
|
||||
state.lastChunkPeak = pcm16lePeakAbs(resampled);
|
||||
state.peakSinceCommit = Math.max(state.peakSinceCommit, state.lastChunkPeak);
|
||||
try {
|
||||
this.maybeAutoCommitSegment(state);
|
||||
} catch (error) {
|
||||
@@ -325,9 +353,7 @@ export class DictationStreamManager {
|
||||
return state.finalTranscriptSegmentIds.has(segmentId) ? count : count + 1;
|
||||
}, 0);
|
||||
const pendingSegments =
|
||||
pendingCommittedSegments +
|
||||
pendingUncommittedTranscriptSegments +
|
||||
(state.awaitingFinalCommit ? 1 : 0);
|
||||
pendingCommittedSegments + pendingUncommittedTranscriptSegments + state.pendingCommits;
|
||||
const pendingAudioSeconds = Math.ceil(Math.max(0, state.bytesSinceCommit) / bytesPerSecond);
|
||||
const missingSeqCount =
|
||||
state.finalSeq === null ? 0 : Math.max(0, state.finalSeq - state.ackSeq);
|
||||
@@ -347,19 +373,36 @@ export class DictationStreamManager {
|
||||
if (state.finishRequested) {
|
||||
return;
|
||||
}
|
||||
if (state.autoCommitBytes <= 0 || state.bytesSinceCommit < state.autoCommitBytes) {
|
||||
if (!shouldSplitSegment(state)) {
|
||||
return;
|
||||
}
|
||||
if (state.peakSinceCommit < SILENCE_PEAK_THRESHOLD) {
|
||||
state.stt.clear();
|
||||
state.bytesSinceCommit = 0;
|
||||
state.peakSinceCommit = 0;
|
||||
state.lastChunkPeak = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
state.bytesSinceCommit = 0;
|
||||
state.peakSinceCommit = 0;
|
||||
state.stt.commit();
|
||||
state.lastChunkPeak = 0;
|
||||
this.commitSegment(state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Issue a commit and record it as in flight. The session acknowledges with a
|
||||
* `committed` event; until then the manager must not finalize, or the
|
||||
* segment's transcript would be missing from the final text.
|
||||
*/
|
||||
commitSegment(state) {
|
||||
state.pendingCommits += 1;
|
||||
try {
|
||||
state.stt.commit();
|
||||
} catch (error) {
|
||||
state.pendingCommits -= 1;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
maybeSealStreamFinish(dictationId) {
|
||||
@@ -382,19 +425,19 @@ export class DictationStreamManager {
|
||||
state.stt.clear();
|
||||
state.bytesSinceCommit = 0;
|
||||
state.peakSinceCommit = 0;
|
||||
state.awaitingFinalCommit = false;
|
||||
state.lastChunkPeak = 0;
|
||||
this.dropUncommittedNonFinalTranscripts(state);
|
||||
} else {
|
||||
state.awaitingFinalCommit = true;
|
||||
state.bytesSinceCommit = 0;
|
||||
state.peakSinceCommit = 0;
|
||||
state.lastChunkPeak = 0;
|
||||
try {
|
||||
state.stt.commit();
|
||||
this.commitSegment(state);
|
||||
} catch (error) {
|
||||
this.failAndCleanupStream(dictationId, error?.message || String(error), true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
state.awaitingFinalCommit = false;
|
||||
}
|
||||
|
||||
state.finishSealed = true;
|
||||
@@ -425,7 +468,7 @@ export class DictationStreamManager {
|
||||
if (state.ackSeq < state.finalSeq) {
|
||||
return;
|
||||
}
|
||||
if (state.awaitingFinalCommit) {
|
||||
if (state.pendingCommits > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -175,8 +175,8 @@ describe('DictationStreamManager', () => {
|
||||
},
|
||||
});
|
||||
const { manager, messages } = createManager(session);
|
||||
// Force auto-commit after ~0.05s of audio so two segments form.
|
||||
manager.autoCommitSeconds = 0.05;
|
||||
// Force a hard-cap split after ~0.05s of audio so two segments form.
|
||||
manager.segmentMaxSeconds = 0.05;
|
||||
|
||||
await manager.handleStart('d1', FORMAT, {});
|
||||
manager.handleChunk({ dictationId: 'd1', seq: 0, audioBase64: loudChunkBase64(1600) });
|
||||
@@ -191,4 +191,62 @@ describe('DictationStreamManager', () => {
|
||||
const partials = messages.filter((m) => m.type === 'partial');
|
||||
expect(partials.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('keeps a short dictation as one segment even across pauses', async () => {
|
||||
const session = new FakeSttSession();
|
||||
const { manager } = createManager(session);
|
||||
|
||||
await manager.handleStart('d1', FORMAT, {});
|
||||
manager.handleChunk({ dictationId: 'd1', seq: 0, audioBase64: loudChunkBase64(16000) });
|
||||
manager.handleChunk({ dictationId: 'd1', seq: 1, audioBase64: silentChunkBase64(16000) });
|
||||
manager.handleChunk({ dictationId: 'd1', seq: 2, audioBase64: loudChunkBase64(16000) });
|
||||
|
||||
expect(session.commits).toBe(0);
|
||||
|
||||
manager.handleFinish('d1', 2);
|
||||
await waitFor(() => session.commits === 1);
|
||||
});
|
||||
|
||||
it('splits at a pause once the segment passes the minimum length', async () => {
|
||||
const session = new FakeSttSession();
|
||||
const { manager } = createManager(session);
|
||||
manager.segmentMinSeconds = 3;
|
||||
|
||||
await manager.handleStart('d1', FORMAT, {});
|
||||
// 2s of audio: below the minimum, so this pause must not split.
|
||||
manager.handleChunk({ dictationId: 'd1', seq: 0, audioBase64: loudChunkBase64(16000) });
|
||||
manager.handleChunk({ dictationId: 'd1', seq: 1, audioBase64: silentChunkBase64(16000) });
|
||||
expect(session.commits).toBe(0);
|
||||
|
||||
// Past the minimum, the next quiet chunk is a segment boundary.
|
||||
manager.handleChunk({ dictationId: 'd1', seq: 2, audioBase64: loudChunkBase64(16000) });
|
||||
expect(session.commits).toBe(0);
|
||||
manager.handleChunk({ dictationId: 'd1', seq: 3, audioBase64: silentChunkBase64(16000) });
|
||||
expect(session.commits).toBe(1);
|
||||
});
|
||||
|
||||
it('splits pauseless speech at the hard cap', async () => {
|
||||
const session = new FakeSttSession();
|
||||
const { manager } = createManager(session);
|
||||
manager.segmentMinSeconds = 60;
|
||||
manager.segmentMaxSeconds = 2;
|
||||
|
||||
await manager.handleStart('d1', FORMAT, {});
|
||||
manager.handleChunk({ dictationId: 'd1', seq: 0, audioBase64: loudChunkBase64(16000) });
|
||||
expect(session.commits).toBe(0);
|
||||
manager.handleChunk({ dictationId: 'd1', seq: 1, audioBase64: loudChunkBase64(16000) });
|
||||
expect(session.commits).toBe(1);
|
||||
});
|
||||
|
||||
it('clears a silence-only segment at the hard cap instead of committing it', async () => {
|
||||
const session = new FakeSttSession();
|
||||
const { manager } = createManager(session);
|
||||
manager.segmentMaxSeconds = 1;
|
||||
|
||||
await manager.handleStart('d1', FORMAT, {});
|
||||
manager.handleChunk({ dictationId: 'd1', seq: 0, audioBase64: silentChunkBase64(16000) });
|
||||
|
||||
expect(session.commits).toBe(0);
|
||||
expect(session.clears).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import path from 'path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { mintOutsideFileGrant, registerFsRoutes } from './routes.js';
|
||||
import { createProjectDirectoryRuntime } from '../opencode/project-directory-runtime.js';
|
||||
|
||||
const createRouteRegistry = () => {
|
||||
const routes = new Map();
|
||||
@@ -1048,3 +1049,78 @@ describe('fs list symlink path space (issue 2627)', () => {
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('fs stat directory scope (issue 3019)', () => {
|
||||
// Wires the real project-directory runtime so the stat route resolves the
|
||||
// workspace exactly as the server does: explicit x-opencode-directory header
|
||||
// first, then the settings.lastDirectory fallback. The renderer's file
|
||||
// reference probes must send the header because lastDirectory reflects the
|
||||
// directory the UI last browsed, not the session's directory.
|
||||
const registerStatWithProjectDirectoryRuntime = () => {
|
||||
const projectDirectoryRuntime = createProjectDirectoryRuntime({
|
||||
fsPromises: {
|
||||
stat: async (targetPath) => {
|
||||
if (targetPath === '/repo-a' || targetPath === '/repo-b') {
|
||||
return { isDirectory: () => true };
|
||||
}
|
||||
return { isDirectory: () => false, isFile: () => true, size: 12 };
|
||||
},
|
||||
realpath: async (targetPath) => targetPath,
|
||||
},
|
||||
path: { resolve: (p) => path.posix.resolve(p) },
|
||||
normalizeDirectoryPath: (p) => p,
|
||||
readSettingsFromDiskMigrated: async () => ({ lastDirectory: '/repo-a', projects: [] }),
|
||||
getReadSettingsFromDiskMigrated: undefined,
|
||||
sanitizeProjects: (input) => input,
|
||||
});
|
||||
|
||||
const { app, getRoute } = createRouteRegistry();
|
||||
registerFsRoutes(app, {
|
||||
os: { homedir: () => '/home/user' },
|
||||
path: path.posix,
|
||||
fsPromises: {
|
||||
realpath: async (targetPath) => targetPath,
|
||||
stat: async () => ({ isFile: () => true, size: 12 }),
|
||||
},
|
||||
spawn: vi.fn(),
|
||||
crypto: { randomUUID: () => 'job-0' },
|
||||
normalizeDirectoryPath: (p) => p,
|
||||
resolveProjectDirectory: projectDirectoryRuntime.resolveProjectDirectory,
|
||||
buildAugmentedPath: () => '/usr/bin',
|
||||
resolveGitBinaryForSpawn: () => 'git',
|
||||
openchamberUserConfigRoot: '/home/user/.config',
|
||||
});
|
||||
return getRoute('GET', '/api/fs/stat');
|
||||
};
|
||||
|
||||
const callStat = async (handler, { headers = {}, query }) => {
|
||||
const res = createMockResponse();
|
||||
const req = {
|
||||
query,
|
||||
get: (name) => headers[name.toLowerCase()] ?? undefined,
|
||||
};
|
||||
await handler(req, res);
|
||||
return res;
|
||||
};
|
||||
|
||||
it('rejects a stat for a file under the session directory when only lastDirectory resolves the workspace', async () => {
|
||||
const handler = registerStatWithProjectDirectoryRuntime();
|
||||
|
||||
const res = await callStat(handler, { query: { path: '/repo-b/src/index.ts', optional: 'true' } });
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body).toEqual({ error: 'Path is outside of active workspace' });
|
||||
});
|
||||
|
||||
it('accepts the same stat when the session directory rides the x-opencode-directory header', async () => {
|
||||
const handler = registerStatWithProjectDirectoryRuntime();
|
||||
|
||||
const res = await callStat(handler, {
|
||||
headers: { 'x-opencode-directory': '/repo-b' },
|
||||
query: { path: '/repo-b/src/index.ts', optional: 'true' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body.isFile).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -428,6 +428,49 @@ export function registerGitRoutes(app) {
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/git/branch-base', async (req, res) => {
|
||||
const { getBranchBase } = await getGitLibraries();
|
||||
try {
|
||||
const directory = resolveDirectoryQuery(req.query.directory);
|
||||
if (!directory) {
|
||||
return res.status(400).json({ error: 'directory parameter is required' });
|
||||
}
|
||||
|
||||
const branch = resolveDirectoryQuery(req.query.branch);
|
||||
if (!branch) {
|
||||
return res.status(400).json({ error: 'branch parameter is required' });
|
||||
}
|
||||
|
||||
const result = await getBranchBase(directory, branch);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('Failed to get branch base:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to get branch base' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/git/range-files', async (req, res) => {
|
||||
const { getRangeFiles } = await getGitLibraries();
|
||||
try {
|
||||
const directory = resolveDirectoryQuery(req.query.directory);
|
||||
if (!directory) {
|
||||
return res.status(400).json({ error: 'directory parameter is required' });
|
||||
}
|
||||
|
||||
const base = resolveDirectoryQuery(req.query.base);
|
||||
const head = resolveDirectoryQuery(req.query.head);
|
||||
if (!base || !head) {
|
||||
return res.status(400).json({ error: 'base and head parameters are required' });
|
||||
}
|
||||
|
||||
const files = await getRangeFiles(directory, { base, head });
|
||||
res.json({ files });
|
||||
} catch (error) {
|
||||
console.error('Failed to get git range files:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to get git range files' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/git/revert', async (req, res) => {
|
||||
const { revertFile } = await getGitLibraries();
|
||||
try {
|
||||
|
||||
@@ -2654,6 +2654,71 @@ export async function getRangeDiff(directory, { base, head, path: filePath, cont
|
||||
return diff;
|
||||
}
|
||||
|
||||
const BRANCH_CREATION_SOURCE_RE = /^branch: Created from (.+)$/;
|
||||
|
||||
/**
|
||||
* Parse a branch reflog (`git reflog show --format=%gs <branch>`) and return the
|
||||
* ref the branch was created from, when that source is itself a named ref.
|
||||
*
|
||||
* Returns null when the branch was created from `HEAD@{...}` or a raw commit
|
||||
* (detached start): the original branch name is not recorded anywhere in that
|
||||
* case, and guessing a base from commit topology would be a heuristic, not an
|
||||
* answer. Callers should ask the user to pick a base instead.
|
||||
*/
|
||||
export function parseBranchCreationSource(reflogText) {
|
||||
const lines = String(reflogText || '')
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
// Reflog lists newest entries first; the creation entry is the oldest one.
|
||||
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
||||
const match = lines[index].match(BRANCH_CREATION_SOURCE_RE);
|
||||
if (!match) continue;
|
||||
const source = match[1].trim();
|
||||
if (!source || /^HEAD@/.test(source) || /^[0-9a-f]{7,40}$/i.test(source)) {
|
||||
return null;
|
||||
}
|
||||
return source;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the branch the given branch was created from, from its reflog.
|
||||
* Returns { base: null } when git has no authoritative record (clone, detached
|
||||
* start, reflog expired) — callers must not fall back to main/master.
|
||||
*/
|
||||
export async function getBranchBase(directory, branch) {
|
||||
const branchName = String(branch || '').trim();
|
||||
if (!branchName) {
|
||||
throw new Error('branch is required');
|
||||
}
|
||||
|
||||
const { git } = await createRepositoryGitContext(directory);
|
||||
|
||||
let reflog = '';
|
||||
try {
|
||||
reflog = await git.raw(['reflog', 'show', '--format=%gs', branchName]);
|
||||
} catch {
|
||||
return { base: null };
|
||||
}
|
||||
|
||||
const source = parseBranchCreationSource(reflog);
|
||||
if (!source || source === branchName) {
|
||||
return { base: null };
|
||||
}
|
||||
|
||||
const resolves = await git
|
||||
.raw(['rev-parse', '--verify', '--quiet', source])
|
||||
.then((value) => Boolean(String(value || '').trim()))
|
||||
.catch(() => false);
|
||||
if (!resolves) {
|
||||
return { base: null };
|
||||
}
|
||||
|
||||
return { base: source };
|
||||
}
|
||||
|
||||
export async function getRangeFiles(directory, { base, head } = {}) {
|
||||
const { git } = await createRepositoryGitContext(directory);
|
||||
const baseRef = typeof base === 'string' ? base.trim() : '';
|
||||
@@ -2673,11 +2738,26 @@ export async function getRangeFiles(directory, { base, head } = {}) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
const raw = await git.raw(['diff', '--name-only', `${resolvedBase}...${headRef}`]);
|
||||
return String(raw || '')
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
// `-C` (copy detection among changed files only, so cheap) makes copies
|
||||
// surface as C entries instead of plain additions; rename detection is on
|
||||
// by default.
|
||||
const raw = await git.raw(['diff', '--name-status', '-z', '-C', `${resolvedBase}...${headRef}`]);
|
||||
// -z format: STATUS\0PATH\0[ORIG\0] repeated. For rename/copy entries
|
||||
// (`R100`, `C75`) the first path token is the ORIGINAL path and the second
|
||||
// is the DESTINATION — the diff (and the UI) must address the destination.
|
||||
const tokens = String(raw || '').split('\0');
|
||||
const files = [];
|
||||
for (let index = 0; index < tokens.length; index += 1) {
|
||||
const status = (tokens[index] || '').trim();
|
||||
if (!status) continue;
|
||||
const isRenameOrCopy = status.startsWith('R') || status.startsWith('C');
|
||||
const path = isRenameOrCopy ? (tokens[index + 2] || '').trim() : (tokens[index + 1] || '').trim();
|
||||
index += isRenameOrCopy ? 2 : 1;
|
||||
if (path) {
|
||||
files.push({ path, status: status.charAt(0) });
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
const IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico', 'bmp', 'avif'];
|
||||
|
||||
@@ -28,6 +28,8 @@ import {
|
||||
getDiff,
|
||||
getFileDiff,
|
||||
validateWorktreeCreate,
|
||||
parseBranchCreationSource,
|
||||
getRangeFiles,
|
||||
} from './service.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1336,3 +1338,94 @@ describe.runIf(canRunGit())('getRangeDiff', () => {
|
||||
expect(diff).toContain('feature.txt');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseBranchCreationSource', () => {
|
||||
it('returns the source ref from the oldest creation entry', () => {
|
||||
// Reflog lists newest entries first; creation is the last line.
|
||||
const reflog = [
|
||||
'commit: abc123',
|
||||
'branch: Created from origin/main',
|
||||
].join('\n');
|
||||
expect(parseBranchCreationSource(reflog)).toBe('origin/main');
|
||||
});
|
||||
|
||||
it('returns null when the branch was created from a detached HEAD pointer', () => {
|
||||
const reflog = 'branch: Created from HEAD@{0}';
|
||||
expect(parseBranchCreationSource(reflog)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the branch was created from a raw commit', () => {
|
||||
const reflog = 'branch: Created from 9a3b2c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b';
|
||||
expect(parseBranchCreationSource(reflog)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when there is no creation entry', () => {
|
||||
const reflog = ['commit: abc123', 'reset: moving to HEAD'].join('\n');
|
||||
expect(parseBranchCreationSource(reflog)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for empty input', () => {
|
||||
expect(parseBranchCreationSource('')).toBeNull();
|
||||
expect(parseBranchCreationSource(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe.runIf(canRunGit())('getRangeFiles', () => {
|
||||
it('returns added and modified paths with their status letters', async () => {
|
||||
const { repository } = createRepositoryWithRemote();
|
||||
fs.writeFileSync(path.join(repository, 'added.txt'), 'new\n');
|
||||
fs.writeFileSync(path.join(repository, 'README.md'), '# Test\nchanged\n');
|
||||
runGit(repository, ['add', 'added.txt', 'README.md']);
|
||||
runGit(repository, ['commit', '-m', 'changes']);
|
||||
|
||||
const files = await getRangeFiles(repository, { base: 'react', head: 'next' });
|
||||
|
||||
expect(files).toEqual(expect.arrayContaining([
|
||||
{ path: 'added.txt', status: 'A' },
|
||||
{ path: 'README.md', status: 'M' },
|
||||
]));
|
||||
});
|
||||
|
||||
it('reports the destination path for renamed files, including spaces', async () => {
|
||||
const { repository } = createRepositoryWithRemote();
|
||||
// The original file must exist in the base: rename detection pairs a
|
||||
// deletion against an addition relative to base, not within the branch.
|
||||
fs.writeFileSync(path.join(repository, 'old name with spaces.md'), '# Test\n');
|
||||
runGit(repository, ['add', 'old name with spaces.md']);
|
||||
runGit(repository, ['commit', '-m', 'add file to rename']);
|
||||
runGit(repository, ['push', 'origin', 'HEAD:react']);
|
||||
// Spaces in filenames exercise the -z token split: a newline split would
|
||||
// mangle these paths long before status letters matter.
|
||||
fs.renameSync(path.join(repository, 'old name with spaces.md'), path.join(repository, 'new name with spaces.md'));
|
||||
runGit(repository, ['add', '-A']);
|
||||
runGit(repository, ['commit', '-m', 'rename']);
|
||||
|
||||
const files = await getRangeFiles(repository, { base: 'react', head: 'next' });
|
||||
|
||||
const renameEntry = files.find((file) => file.status === 'R');
|
||||
expect(renameEntry).toBeDefined();
|
||||
expect(renameEntry.path).toBe('new name with spaces.md');
|
||||
expect(files.some((file) => file.path === 'old name with spaces.md')).toBe(false);
|
||||
});
|
||||
|
||||
it('reports the destination path for copied files', async () => {
|
||||
const { repository } = createRepositoryWithRemote();
|
||||
// The source must exist in the base. Copy detection needs the repository's
|
||||
// own `diff.renames=copies` setting on top of the service's -C flag; the
|
||||
// parser must survive whatever C entries git emits.
|
||||
runGit(repository, ['config', 'diff.renames', 'copies']);
|
||||
fs.writeFileSync(path.join(repository, 'copied source.md'), '# Copy me\n');
|
||||
runGit(repository, ['add', 'copied source.md']);
|
||||
runGit(repository, ['commit', '-m', 'add source']);
|
||||
runGit(repository, ['push', 'origin', 'HEAD:react']);
|
||||
fs.copyFileSync(path.join(repository, 'copied source.md'), path.join(repository, 'copied destination.md'));
|
||||
runGit(repository, ['add', '-A']);
|
||||
runGit(repository, ['commit', '-m', 'copy']);
|
||||
|
||||
const files = await getRangeFiles(repository, { base: 'react', head: 'next' });
|
||||
|
||||
const copyEntry = files.find((file) => file.status === 'C');
|
||||
expect(copyEntry).toBeDefined();
|
||||
expect(copyEntry.path).toBe('copied destination.md');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -207,7 +207,8 @@ Managed health failures are classified as `timeout`, `connection_refused`, `conn
|
||||
- `readSettingsFromDiskMigrated()`
|
||||
- `writeSettingsToDisk(settings)`
|
||||
- `persistSettings(changes)`
|
||||
- Persistent permission auto-accept policy is stored under `permissionAutoAccept`; execution ownership lives in `lib/permission-auto-accept/`.
|
||||
- Persistent permission auto-accept policy is stored under `permissionAutoAccept`; execution ownership lives in `lib/permission-auto-accept/`.
|
||||
- Shared sidebar preferences are stored as validated top-level fields: `sidebarProjectDisplayMode`, `sidebarSessionGroupingMode`, `sidebarProjectSortOrder`, and `sidebarShowRecentSection`. Device-local picker selection and sticky-header state do not enter `settings.json`.
|
||||
|
||||
## Public exports (settings-helpers.js)
|
||||
- `createSettingsHelpers(dependencies)`: creates settings helper runtime for settings request/response shaping.
|
||||
|
||||
@@ -29,6 +29,9 @@ export const createSettingsHelpers = (dependencies) => {
|
||||
const PWA_ORIENTATION_VALUES = new Set(['system', 'portrait', 'landscape']);
|
||||
const MOBILE_KEYBOARD_MODE_VALUES = new Set(['native', 'resize-content']);
|
||||
const TERMINAL_SHELL_VALUES = new Set(['auto', 'bash', 'zsh', 'sh', 'fish', 'pwsh', 'powershell', 'cmd', 'dash', 'ksh', 'nu']);
|
||||
const SIDEBAR_PROJECT_DISPLAY_MODE_VALUES = new Set(['all', 'single']);
|
||||
const SIDEBAR_SESSION_GROUPING_MODE_VALUES = new Set(['by-worktree', 'flat']);
|
||||
const SIDEBAR_PROJECT_SORT_ORDER_VALUES = new Set(['manual', 'a-z', 'z-a', 'date-added', 'recent']);
|
||||
const HIDDEN_MODELS_MAX = 1024;
|
||||
const RECENT_EFFORTS_MAX_KEYS = 128;
|
||||
const RECENT_EFFORTS_MAX_VARIANTS_PER_KEY = 5;
|
||||
@@ -243,6 +246,18 @@ export const createSettingsHelpers = (dependencies) => {
|
||||
if (typeof candidate.activeProjectId === 'string' && candidate.activeProjectId.length > 0) {
|
||||
result.activeProjectId = candidate.activeProjectId;
|
||||
}
|
||||
if (SIDEBAR_PROJECT_DISPLAY_MODE_VALUES.has(candidate.sidebarProjectDisplayMode)) {
|
||||
result.sidebarProjectDisplayMode = candidate.sidebarProjectDisplayMode;
|
||||
}
|
||||
if (SIDEBAR_SESSION_GROUPING_MODE_VALUES.has(candidate.sidebarSessionGroupingMode)) {
|
||||
result.sidebarSessionGroupingMode = candidate.sidebarSessionGroupingMode;
|
||||
}
|
||||
if (SIDEBAR_PROJECT_SORT_ORDER_VALUES.has(candidate.sidebarProjectSortOrder)) {
|
||||
result.sidebarProjectSortOrder = candidate.sidebarProjectSortOrder;
|
||||
}
|
||||
if (typeof candidate.sidebarShowRecentSection === 'boolean') {
|
||||
result.sidebarShowRecentSection = candidate.sidebarShowRecentSection;
|
||||
}
|
||||
|
||||
if (Array.isArray(candidate.securityScopedBookmarks)) {
|
||||
result.securityScopedBookmarks = normalizeStringArray(candidate.securityScopedBookmarks);
|
||||
|
||||
@@ -66,6 +66,28 @@ describe('settings helpers', () => {
|
||||
expect(helpers.sanitizeSettingsUpdate({ draftStartersVisible: 'false' })).toEqual({});
|
||||
});
|
||||
|
||||
it('sanitizes shared sidebar display preferences', () => {
|
||||
const helpers = createTestHelpers();
|
||||
|
||||
expect(helpers.sanitizeSettingsUpdate({
|
||||
sidebarProjectDisplayMode: 'single',
|
||||
sidebarSessionGroupingMode: 'flat',
|
||||
sidebarProjectSortOrder: 'z-a',
|
||||
sidebarShowRecentSection: false,
|
||||
})).toEqual({
|
||||
sidebarProjectDisplayMode: 'single',
|
||||
sidebarSessionGroupingMode: 'flat',
|
||||
sidebarProjectSortOrder: 'z-a',
|
||||
sidebarShowRecentSection: false,
|
||||
});
|
||||
expect(helpers.sanitizeSettingsUpdate({
|
||||
sidebarProjectDisplayMode: 'grid',
|
||||
sidebarSessionGroupingMode: 'project',
|
||||
sidebarProjectSortOrder: 'random',
|
||||
sidebarShowRecentSection: 'false',
|
||||
})).toEqual({});
|
||||
});
|
||||
|
||||
it('accepts only booleans for wide chat layout', () => {
|
||||
const helpers = createTestHelpers();
|
||||
|
||||
|
||||
@@ -155,6 +155,7 @@ export const createSettingsNormalizationRuntime = (dependencies) => {
|
||||
const iconBackground = normalizeIconBackground(candidate.iconBackground);
|
||||
const color = typeof candidate.color === 'string' ? candidate.color.trim() : '';
|
||||
const defaultModel = typeof candidate.defaultModel === 'string' ? candidate.defaultModel.trim() : '';
|
||||
const defaultVariant = typeof candidate.defaultVariant === 'string' ? candidate.defaultVariant.trim() : '';
|
||||
const addedAt = Number.isFinite(candidate.addedAt) ? Number(candidate.addedAt) : null;
|
||||
const lastOpenedAt = Number.isFinite(candidate.lastOpenedAt)
|
||||
? Number(candidate.lastOpenedAt)
|
||||
@@ -175,6 +176,8 @@ export const createSettingsNormalizationRuntime = (dependencies) => {
|
||||
...(iconBackground ? { iconBackground } : {}),
|
||||
...(color ? { color } : {}),
|
||||
...(defaultModel && defaultModel.includes('/') ? { defaultModel } : {}),
|
||||
// A variant is meaningless without the model it belongs to.
|
||||
...(defaultModel && defaultModel.includes('/') && defaultVariant ? { defaultVariant } : {}),
|
||||
...(Number.isFinite(addedAt) && addedAt >= 0 ? { addedAt } : {}),
|
||||
...(Number.isFinite(lastOpenedAt) && lastOpenedAt >= 0 ? { lastOpenedAt } : {}),
|
||||
};
|
||||
|
||||
@@ -106,6 +106,22 @@ describe('settings normalization runtime - symlink resolution', () => {
|
||||
expect(result[0].path).toBe('/resolved/missing/path');
|
||||
});
|
||||
|
||||
it('keeps a default thinking level next to its model and drops it alone', () => {
|
||||
const runtime = createTestRuntime({
|
||||
realpathSync: (p) => p,
|
||||
path: { resolve: (p) => p, sep: '/', dirname: (p) => p.split('/').slice(0, -1).join('/') || '/' },
|
||||
});
|
||||
|
||||
const projects = [
|
||||
{ id: 'proj1', path: '/a', defaultModel: 'anthropic/claude-opus-5', defaultVariant: 'high' },
|
||||
{ id: 'proj2', path: '/b', defaultVariant: 'high' },
|
||||
];
|
||||
|
||||
const result = runtime.sanitizeProjects(projects);
|
||||
expect(result[0].defaultVariant).toBe('high');
|
||||
expect(result[1].defaultVariant).toBe(undefined);
|
||||
});
|
||||
|
||||
it('deduplicates projects that resolve to the same realpath', () => {
|
||||
const runtime = createTestRuntime({
|
||||
realpathSync: (p) => p.startsWith('/symlink') ? '/real/project' : p,
|
||||
|
||||
@@ -547,25 +547,41 @@ export const createSettingsRuntime = (deps) => {
|
||||
// briefly opens the target file. Preserve atomic rename everywhere it works,
|
||||
// but fall back to a direct replacement so settings persistence does not
|
||||
// get permanently wedged on Windows desktop installs.
|
||||
await fsPromises.copyFile(tmp, target);
|
||||
await fsPromises.rm(tmp, { force: true });
|
||||
try {
|
||||
await fsPromises.copyFile(tmp, target);
|
||||
} finally {
|
||||
await fsPromises.rm(tmp, { force: true }).catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
const cleanupOrphanedSettingsTempFiles = async (directory) => {
|
||||
try {
|
||||
const entries = await fsPromises.readdir(directory, { withFileTypes: true });
|
||||
const cleanupTasks = entries
|
||||
.filter((entry) => entry.isFile() && entry.name.startsWith('settings.json.tmp-'))
|
||||
.map((entry) => fsPromises.rm(path.join(directory, entry.name), { force: true }).catch(() => {}));
|
||||
await Promise.all(cleanupTasks);
|
||||
} catch {
|
||||
// Best-effort cleanup: errors reading directory must not fail settings operations
|
||||
}
|
||||
};
|
||||
|
||||
const writeSettingsToDisk = async (settings) => {
|
||||
const settingsDirectory = path.dirname(SETTINGS_FILE_PATH);
|
||||
await fsPromises.mkdir(settingsDirectory, { recursive: true, mode: 0o700 });
|
||||
if (process.platform !== 'win32') await fsPromises.chmod(settingsDirectory, 0o700);
|
||||
// Atomic write: Electron main and ssh-manager read this file via plain
|
||||
// readFile + JSON.parse and silently coerce parse errors to {}. A
|
||||
// partial read during a non-atomic writeFile would make their next
|
||||
// read-modify-write wipe the settings file.
|
||||
const tmp = `${SETTINGS_FILE_PATH}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
try {
|
||||
const settingsDirectory = path.dirname(SETTINGS_FILE_PATH);
|
||||
await fsPromises.mkdir(settingsDirectory, { recursive: true, mode: 0o700 });
|
||||
if (process.platform !== 'win32') await fsPromises.chmod(settingsDirectory, 0o700);
|
||||
// Atomic write: Electron main and ssh-manager read this file via plain
|
||||
// readFile + JSON.parse and silently coerce parse errors to {}. A
|
||||
// partial read during a non-atomic writeFile would make their next
|
||||
// read-modify-write wipe the settings file.
|
||||
const tmp = `${SETTINGS_FILE_PATH}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
await fsPromises.writeFile(tmp, JSON.stringify(settings, null, 2), { encoding: 'utf8', mode: 0o600 });
|
||||
if (process.platform !== 'win32') await fsPromises.chmod(tmp, 0o600);
|
||||
await replaceFile(tmp, SETTINGS_FILE_PATH);
|
||||
if (process.platform !== 'win32') await fsPromises.chmod(SETTINGS_FILE_PATH, 0o600);
|
||||
} catch (error) {
|
||||
await fsPromises.rm(tmp, { force: true }).catch(() => {});
|
||||
console.warn('Failed to write settings file:', error);
|
||||
throw error;
|
||||
}
|
||||
@@ -854,7 +870,13 @@ export const createSettingsRuntime = (deps) => {
|
||||
return { settings: next, changed: true };
|
||||
};
|
||||
|
||||
let hasCleanedOrphanedTempFiles = false;
|
||||
|
||||
const readSettingsFromDiskMigrated = async () => {
|
||||
if (!hasCleanedOrphanedTempFiles) {
|
||||
hasCleanedOrphanedTempFiles = true;
|
||||
await cleanupOrphanedSettingsTempFiles(path.dirname(SETTINGS_FILE_PATH));
|
||||
}
|
||||
const current = await readSettingsFromDisk();
|
||||
const migration1 = await migrateSettingsFromLegacyLastDirectory(current);
|
||||
const migration2 = await migrateSettingsFromLegacyThemePreferences(migration1.settings);
|
||||
|
||||
@@ -39,6 +39,24 @@ const createRuntime = async () => {
|
||||
};
|
||||
|
||||
describe('settings runtime', () => {
|
||||
it('round-trips shared sidebar preferences through settings.json', async () => {
|
||||
const { runtime, settingsFilePath, cleanup } = await createRuntime();
|
||||
const preferences = {
|
||||
sidebarProjectDisplayMode: 'single',
|
||||
sidebarSessionGroupingMode: 'flat',
|
||||
sidebarProjectSortOrder: 'date-added',
|
||||
sidebarShowRecentSection: false,
|
||||
};
|
||||
try {
|
||||
await runtime.persistSettings(preferences);
|
||||
|
||||
await expect(runtime.readSettingsFromDisk()).resolves.toEqual(preferences);
|
||||
await expect(fsPromises.readFile(settingsFilePath, 'utf8')).resolves.toBe(JSON.stringify(preferences, null, 2));
|
||||
} finally {
|
||||
await cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === 'win32')('writes settings with restrictive directory and file permissions', async () => {
|
||||
const { runtime, settingsFilePath, tempRoot, cleanup } = await createRuntime();
|
||||
try {
|
||||
@@ -133,4 +151,71 @@ describe('settings runtime', () => {
|
||||
await fsPromises.rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('cleans up orphaned settings.json.tmp files during startup migration', async () => {
|
||||
const { runtime, settingsFilePath, tempRoot, cleanup } = await createRuntime();
|
||||
try {
|
||||
const settingsDir = path.dirname(settingsFilePath);
|
||||
const orphan1 = path.join(settingsDir, 'settings.json.tmp-1234-11111-abc');
|
||||
const orphan2 = path.join(settingsDir, 'settings.json.tmp-5678-22222-def');
|
||||
const unrelated = path.join(settingsDir, 'other-file.json');
|
||||
|
||||
await fsPromises.writeFile(orphan1, '{"broken": true}', 'utf8');
|
||||
await fsPromises.writeFile(orphan2, '{"broken": true}', 'utf8');
|
||||
await fsPromises.writeFile(unrelated, '{"keep": true}', 'utf8');
|
||||
await fsPromises.writeFile(settingsFilePath, '{"theme": "light"}', 'utf8');
|
||||
|
||||
await runtime.readSettingsFromDiskMigrated();
|
||||
|
||||
const files = await fsPromises.readdir(settingsDir);
|
||||
expect(files).toContain('settings.json');
|
||||
expect(files).toContain('other-file.json');
|
||||
expect(files).not.toContain('settings.json.tmp-1234-11111-abc');
|
||||
expect(files).not.toContain('settings.json.tmp-5678-22222-def');
|
||||
} finally {
|
||||
await cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('removes temp file when writeSettingsToDisk encounters a write error', async () => {
|
||||
const tempRoot = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'oc-settings-runtime-'));
|
||||
const settingsFilePath = path.join(tempRoot, 'settings.json');
|
||||
let capturedTmp = null;
|
||||
const wrappedFs = {
|
||||
...fsPromises,
|
||||
rename: async (src, dst) => {
|
||||
capturedTmp = src;
|
||||
const error = new Error('unexpected disk failure');
|
||||
error.code = 'EIO';
|
||||
throw error;
|
||||
},
|
||||
};
|
||||
const runtime = createSettingsRuntime({
|
||||
fsPromises: wrappedFs,
|
||||
path,
|
||||
crypto,
|
||||
SETTINGS_FILE_PATH: settingsFilePath,
|
||||
sanitizeProjects: (projects) => Array.isArray(projects) ? projects : [],
|
||||
sanitizeSettingsUpdate: (settings) => settings,
|
||||
mergePersistedSettings: (_current, changes) => changes,
|
||||
normalizeSettingsPaths: (settings) => ({ settings, changed: false }),
|
||||
normalizeStringArray: (values) => Array.isArray(values) ? values.filter((value) => typeof value === 'string') : [],
|
||||
formatSettingsResponse: (settings) => settings,
|
||||
resolveDirectoryCandidate: (value) => value,
|
||||
normalizeManagedRemoteTunnelHostname: (value) => value,
|
||||
normalizeManagedRemoteTunnelPresets: (value) => value,
|
||||
normalizeManagedRemoteTunnelPresetTokens: (value) => value,
|
||||
syncManagedRemoteTunnelConfigWithPresets: async () => {},
|
||||
upsertManagedRemoteTunnelToken: async () => {},
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(runtime.writeSettingsToDisk({ theme: 'dark' })).rejects.toThrow('unexpected disk failure');
|
||||
expect(capturedTmp).toBeTruthy();
|
||||
const files = await fsPromises.readdir(tempRoot);
|
||||
expect(files.some((f) => f.startsWith('settings.json.tmp-'))).toBe(false);
|
||||
} finally {
|
||||
await fsPromises.rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
Server-owned storage for the Project Notes surface: free-form notes, todos, and
|
||||
plan markdown files.
|
||||
|
||||
The managed Chats root (`~/.config/openchamber/chats`) is also one context owner. Every dated per-session directory beneath it resolves to that root, so Notes, Todo, Plans, pinned knowledge, and project memory are shared across ordinary chats without registering Chats as a user project.
|
||||
|
||||
## Ownership
|
||||
|
||||
| Path | Owner | Contents |
|
||||
|
||||
@@ -231,8 +231,13 @@ export const createProjectContextRuntime = (deps) => {
|
||||
const writeJsonAtomic = async (filePath, value) => {
|
||||
const temporaryPath = `${filePath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
await fsPromises.mkdir(path.dirname(filePath), { recursive: true });
|
||||
await fsPromises.writeFile(temporaryPath, JSON.stringify(value, null, 2), 'utf8');
|
||||
await fsPromises.rename(temporaryPath, filePath);
|
||||
try {
|
||||
await fsPromises.writeFile(temporaryPath, JSON.stringify(value, null, 2), 'utf8');
|
||||
await fsPromises.rename(temporaryPath, filePath);
|
||||
} catch (error) {
|
||||
await fsPromises.rm(temporaryPath, { force: true }).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const withWriteLock = async (projectId, mutate) => {
|
||||
|
||||
@@ -536,8 +536,13 @@ export const createProjectConfigRuntime = (deps) => {
|
||||
};
|
||||
|
||||
await fsPromises.mkdir(parentDirectory, { recursive: true });
|
||||
await fsPromises.writeFile(temporaryPath, JSON.stringify(merged, null, 2), 'utf8');
|
||||
await fsPromises.rename(temporaryPath, filePath);
|
||||
try {
|
||||
await fsPromises.writeFile(temporaryPath, JSON.stringify(merged, null, 2), 'utf8');
|
||||
await fsPromises.rename(temporaryPath, filePath);
|
||||
} catch (error) {
|
||||
await fsPromises.rm(temporaryPath, { force: true }).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const withProjectWriteLock = async (projectID, mutate) => {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { asObject, buildResult, getAuthEntry, normalizeAuthEntry, toNumber, toUs
|
||||
|
||||
export const providerId = 'command-code';
|
||||
export const providerName = 'Command Code';
|
||||
export const aliases = ['command-code'];
|
||||
export const aliases = ['command-code', 'commandcode', 'command_code', 'command code'];
|
||||
|
||||
const API_BASE_URL = 'https://api.commandcode.ai';
|
||||
|
||||
|
||||
@@ -69,4 +69,17 @@ describe('Command Code quota provider', () => {
|
||||
expect(fetchMock.mock.calls[0][1].headers.Authorization).toBe('Bearer test-token');
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('recognizes Command Code auth entries under supported provider ID variants', async () => {
|
||||
for (const providerId of ['commandcode', 'command_code', 'command code']) {
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ org: { id: 'org-1' } })))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify(creditsPayload)));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const result = await fetchQuota({ [providerId]: { type: 'oauth', access: 'test-token' } });
|
||||
expect(result).toMatchObject({ providerId: 'command-code', ok: true, configured: true });
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -160,6 +160,13 @@ const registry = {
|
||||
|
||||
const pendingFetches = new Map();
|
||||
|
||||
const normalizeQuotaProviderId = (providerId) => {
|
||||
if (typeof providerId !== 'string') return providerId;
|
||||
return ['command-code', 'commandcode', 'command_code', 'command code'].includes(providerId.trim().toLowerCase())
|
||||
? 'command-code'
|
||||
: providerId;
|
||||
};
|
||||
|
||||
export const listConfiguredQuotaProviders = () => {
|
||||
const configured = [];
|
||||
|
||||
@@ -203,13 +210,14 @@ const fetchQuotaForProviderUncoalesced = async (providerId) => {
|
||||
};
|
||||
|
||||
export const fetchQuotaForProvider = (providerId) => {
|
||||
const existing = pendingFetches.get(providerId);
|
||||
const normalizedProviderId = normalizeQuotaProviderId(providerId);
|
||||
const existing = pendingFetches.get(normalizedProviderId);
|
||||
if (existing) return existing;
|
||||
|
||||
const pending = fetchQuotaForProviderUncoalesced(providerId).finally(() => {
|
||||
if (pendingFetches.get(providerId) === pending) pendingFetches.delete(providerId);
|
||||
const pending = fetchQuotaForProviderUncoalesced(normalizedProviderId).finally(() => {
|
||||
if (pendingFetches.get(normalizedProviderId) === pending) pendingFetches.delete(normalizedProviderId);
|
||||
});
|
||||
pendingFetches.set(providerId, pending);
|
||||
pendingFetches.set(normalizedProviderId, pending);
|
||||
return pending;
|
||||
};
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ Host side (`packages/web/server/lib/relay/`):
|
||||
- `identity.js` — the host's stable identity: the long-lived signing keypair (shared with the push relay, defines the routing id) plus a long-lived encryption keypair (the E2EE trust anchor). Reused across restarts; never rotated implicitly.
|
||||
- `signing-key.js` — storage/derivation of the signing keypair and the routing id, shared with the notifications runtime.
|
||||
- `host-client.js` — the long-lived connection manager: one outbound control connection to the relay, a per-client data connection for each connected device, reconnect/backoff, and the E2EE responder handshake per connection.
|
||||
- `host-lock.js` — the per-machine host claim. Every local instance sharing the data dir shares the relay identity (same serverId), so concurrent relay hosts evict each other at the relay worker (`4001: Control replaced`) and paired devices land on whichever local process won last. The claim file (`<data-dir>/relay-host.lock`, `{ pid }`) makes this deterministic: `service.js` only starts the host when no LIVE process holds the claim (stale claims from dead pids are ignored), goes to `standby` otherwise, and a 30s watcher both takes over when the claimant dies and stands down when another process claims. Explicit user intent — creating a pairing link or hitting `/relay/enable` — force-claims; the previous holder's watcher sees the takeover and backs off instead of fighting. The claim is cooperative (the relay worker still enforces the single host slot); it only decides which process keeps retrying.
|
||||
- `host-lock.js` — the per-machine host claim. Every local instance sharing the data dir shares the relay identity (same serverId), so concurrent relay hosts evict each other at the relay worker (`4001: Control replaced`) and paired devices land on whichever local process won last. The claim file (`<data-dir>/relay-host.lock`, `{ pid }`) makes this deterministic: `service.js` only starts the host when no LIVE process holds the claim (stale claims from dead pids are ignored), goes to `standby` otherwise, and a 30s watcher both takes over when the claimant dies and stands down when another process claims. A standby watcher waits a 2-minute grace after the claim frees before taking over, so a cleanly restarting host (app update/relaunch) — which reclaims at boot with no wait — always wins the restart window over a bystander instance. Explicit user intent — creating a pairing link or hitting `/relay/enable` — force-claims; the previous holder's watcher sees the takeover and backs off instead of fighting. Instances created with `allowPassiveHost: false` (dev servers via `OPENCHAMBER_RELAY_HOST=off`, the Electron dev shell via `OPENCHAMBER_ELECTRON_DEV`; `OPENCHAMBER_RELAY_HOST=on` overrides) never start the host passively at all — boot, demand reconcile, and watcher takeover leave them in `standby`; only explicit enable/pairing hosts there. The claim is cooperative (the relay worker still enforces the single host slot); it only decides which process keeps retrying.
|
||||
- `tunnel-host.js` — the per-connection dispatcher: decrypts tunnel frames and forwards HTTP/SSE/WS to the local server over loopback, then streams responses back. Enforces a path allowlist and never injects credentials.
|
||||
- `e2ee.js`, `tunnel-codec.js` — host-side (JS) mirrors of the shared crypto and framing (see "Two implementations" below).
|
||||
|
||||
|
||||
@@ -70,6 +70,11 @@ export const createRelayService = ({
|
||||
// evict each other at the relay worker ("Control replaced") and devices land
|
||||
// on a random instance. Optional: without it, behavior is pre-lock.
|
||||
hostLock = null,
|
||||
// When false, this instance never starts the relay host on its own (boot,
|
||||
// demand reconcile, or claim-watch takeover) — only an explicit user action
|
||||
// (enable, pairing) force-claims. Dev/debug instances set this so they do not
|
||||
// capture paired devices from the production instance sharing the data dir.
|
||||
allowPassiveHost = true,
|
||||
logger = console,
|
||||
}) => {
|
||||
const identityRuntime = createRelayIdentityRuntime({ crypto, readSettingsFromDiskMigrated, writeSettingsToDisk, readSettingsStrict });
|
||||
@@ -80,6 +85,13 @@ export const createRelayService = ({
|
||||
// claimant dies; a running host stands down when another process claims.
|
||||
let claimWatchTimer = null;
|
||||
const CLAIM_WATCH_INTERVAL_MS = 30_000;
|
||||
// A standby instance does not grab a freed claim immediately: a clean restart
|
||||
// of the previous host (app update, relaunch) releases the claim for a short
|
||||
// while, and taking it during that window strands the devices on this —
|
||||
// possibly older — instance. The restarting host reclaims at boot without any
|
||||
// wait, so it always wins the window.
|
||||
const CLAIM_TAKEOVER_GRACE_MS = 120_000;
|
||||
let claimFreeSinceMs = null;
|
||||
|
||||
const readConfig = async () => {
|
||||
const settings = await readSettingsFromDiskMigrated();
|
||||
@@ -133,8 +145,19 @@ export const createRelayService = ({
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (status.state === 'standby' && hostLock.tryClaim()) {
|
||||
logger.warn('[Relay] host claim is free — taking over the relay host');
|
||||
if (status.state !== 'standby' || !allowPassiveHost) return;
|
||||
if (hostLock.liveClaimantPid() !== null) {
|
||||
claimFreeSinceMs = null;
|
||||
return;
|
||||
}
|
||||
if (claimFreeSinceMs === null) {
|
||||
claimFreeSinceMs = Date.now();
|
||||
return;
|
||||
}
|
||||
if (Date.now() - claimFreeSinceMs < CLAIM_TAKEOVER_GRACE_MS) return;
|
||||
if (hostLock.tryClaim()) {
|
||||
claimFreeSinceMs = null;
|
||||
logger.warn('[Relay] host claim stayed free — taking over the relay host');
|
||||
await start(relayUrl);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -149,10 +172,19 @@ export const createRelayService = ({
|
||||
if (!claimWatchTimer) return;
|
||||
clearInterval(claimWatchTimer);
|
||||
claimWatchTimer = null;
|
||||
claimFreeSinceMs = null;
|
||||
};
|
||||
|
||||
const start = async (relayUrl, { claim = 'try' } = {}) => {
|
||||
if (hostClient) return;
|
||||
if (claim !== 'force' && !allowPassiveHost) {
|
||||
status = {
|
||||
state: 'standby',
|
||||
lastError: 'passive relay hosting is disabled on this instance — enable the relay or create a pairing link to host here',
|
||||
connectedClients: 0,
|
||||
};
|
||||
return;
|
||||
}
|
||||
if (hostLock) {
|
||||
const claimed = claim === 'force' ? hostLock.forceClaim() : hostLock.tryClaim();
|
||||
if (!claimed) {
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
import { createRelayService } from './service.js';
|
||||
|
||||
const makeService = (options = {}) => {
|
||||
// In-memory settings store with a pre-seeded relay identity so the service
|
||||
// never regenerates a signing key during the test.
|
||||
const { privateKey, publicKey } = crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' });
|
||||
let settings = {
|
||||
relaySigningKey: {
|
||||
privateJwk: privateKey.export({ format: 'jwk' }),
|
||||
publicJwk: publicKey.export({ format: 'jwk' }),
|
||||
},
|
||||
privateRelay: { enabled: true, relayUrl: 'wss://relay.example.test/ws' },
|
||||
...options.settings,
|
||||
};
|
||||
const hostLock = {
|
||||
tryClaim: vi.fn(() => true),
|
||||
forceClaim: vi.fn(() => true),
|
||||
holdsClaim: vi.fn(() => true),
|
||||
liveClaimantPid: vi.fn(() => null),
|
||||
release: vi.fn(),
|
||||
};
|
||||
const service = createRelayService({
|
||||
crypto,
|
||||
readSettingsFromDiskMigrated: async () => settings,
|
||||
writeSettingsToDisk: async (next) => { settings = next; },
|
||||
readSettingsStrict: async () => settings,
|
||||
getLocalPort: () => 0,
|
||||
hasRelayDemand: options.hasRelayDemand ?? (async () => true),
|
||||
hostLock,
|
||||
allowPassiveHost: options.allowPassiveHost,
|
||||
logger: { warn: () => {} },
|
||||
});
|
||||
return { service, hostLock, getSettings: () => settings };
|
||||
};
|
||||
|
||||
describe('relay service passive hosting', () => {
|
||||
it('never claims or starts the host passively when passive hosting is disabled', async () => {
|
||||
const { service, hostLock } = makeService({ allowPassiveHost: false });
|
||||
try {
|
||||
await service.startIfEnabled();
|
||||
let status = await service.getStatus();
|
||||
expect(status.state).toBe('standby');
|
||||
expect(hostLock.tryClaim).not.toHaveBeenCalled();
|
||||
expect(hostLock.forceClaim).not.toHaveBeenCalled();
|
||||
|
||||
await service.reconcile();
|
||||
status = await service.getStatus();
|
||||
expect(status.state).toBe('standby');
|
||||
expect(status.lastError).toContain('passive relay hosting is disabled');
|
||||
expect(hostLock.tryClaim).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
service.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it('force-claims for an explicit pairing even when passive hosting is disabled', async () => {
|
||||
const { service, hostLock } = makeService({ allowPassiveHost: false });
|
||||
try {
|
||||
const candidate = await service.ensureEnabledForPairing();
|
||||
expect(candidate?.type).toBe('relay');
|
||||
expect(hostLock.forceClaim).toHaveBeenCalled();
|
||||
} finally {
|
||||
service.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,8 @@ attached to that session. Pins never come from project-wide note or plan state.
|
||||
A new-session draft passes its pins into this metadata when its first message
|
||||
creates the session.
|
||||
|
||||
Directories beneath the managed `~/.config/openchamber/chats` root resolve to that root before project context and project memory are read. Every ordinary chat therefore shares one Chats knowledge owner instead of creating an unreachable context store for each dated session directory.
|
||||
|
||||
`session.metadata.openchamber.knowledge_context_delivered` holds the signature
|
||||
of what the session is carrying. It lives with the session, so it survives the
|
||||
tab closing and is visible to every sender, including the ones with no tab.
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
|
||||
HTTP remains the authenticated command plane for create, resize, appearance updates, restart, close, and force-kill. There is no SSE output or HTTP input compatibility path.
|
||||
|
||||
`GET /api/terminal/sessions` enumerates live sessions (optionally filtered by resolved `cwd`) so clients can adopt terminals their local tab projection does not know about — another device, a new browser tab, or cleared storage. `POST /api/terminal/touch` refreshes `lastActivity` for the listed session ids; open clients call it periodically so background tabs, which hold no WebSocket attachment, are not idle-reaped while a client still shows them.
|
||||
|
||||
## PTY Lifecycle
|
||||
|
||||
- IDs are client-provided or generated with `randomUUID()`.
|
||||
|
||||
@@ -228,7 +228,7 @@ export function createTerminalRuntime({
|
||||
}
|
||||
if (!existing && sessions.size + pendingSessionCreates.size >= MAX_SESSIONS) throw new Error('Maximum terminal sessions reached');
|
||||
const creation = (async () => {
|
||||
const session = existing ?? { id, sequence: 0, history: '', pendingHistoryControlSequence: '', pendingThemeControlSequence: '', eventQueue: [], draining: false };
|
||||
const session = existing ?? { id, sequence: 0, history: '', pendingHistoryControlSequence: '', pendingThemeControlSequence: '', eventQueue: [], draining: false, createdAt: Date.now() };
|
||||
await startSession(session, { cwd, cols, rows, themeMode, terminalBackground, terminalForeground, shell: normalizedShell, loginShell });
|
||||
sessions.set(id, session);
|
||||
return session;
|
||||
@@ -297,6 +297,34 @@ export function createTerminalRuntime({
|
||||
res.status(500).json({ error: error?.message || 'Failed to list terminal shells' });
|
||||
}
|
||||
});
|
||||
app.get('/api/terminal/sessions', (req, res) => {
|
||||
const rawCwd = typeof req.query?.cwd === 'string' ? req.query.cwd.trim() : '';
|
||||
const cwdFilter = rawCwd ? path.resolve(rawCwd) : null;
|
||||
const list = [];
|
||||
for (const session of sessions.values()) {
|
||||
if (cwdFilter && path.resolve(session.cwd) !== cwdFilter) continue;
|
||||
list.push({
|
||||
sessionId: session.id,
|
||||
cwd: session.cwd,
|
||||
status: session.status,
|
||||
createdAt: Number.isInteger(session.createdAt) ? session.createdAt : null,
|
||||
});
|
||||
}
|
||||
res.json({ sessions: list });
|
||||
});
|
||||
app.post('/api/terminal/touch', (req, res) => {
|
||||
const rawIds = Array.isArray(req.body?.sessionIds) ? req.body.sessionIds : [];
|
||||
const now = Date.now();
|
||||
let touched = 0;
|
||||
for (const id of rawIds) {
|
||||
if (typeof id !== 'string') continue;
|
||||
const session = sessions.get(id);
|
||||
if (!session) continue;
|
||||
session.lastActivity = now;
|
||||
touched += 1;
|
||||
}
|
||||
res.json({ touched });
|
||||
});
|
||||
app.post('/api/terminal/create', async (req, res) => {
|
||||
try { const session = await createSession(req.body ?? {}); res.json({ sessionId: session.id, cols: session.cols, rows: session.rows, status: session.status }); }
|
||||
catch (error) { res.status(error?.message === 'Maximum terminal sessions reached' ? 429 : 400).json({ error: error?.message || 'Failed to create terminal session' }); }
|
||||
|
||||
@@ -179,6 +179,32 @@ describe('terminal runtime', () => {
|
||||
} finally { await harness.runtime.shutdown(); }
|
||||
});
|
||||
|
||||
it('lists sessions scoped to a working directory and refreshes activity via touch', async () => {
|
||||
const harness = createHarness();
|
||||
try {
|
||||
await harness.routes.post.get('/api/terminal/create')({ body: { sessionId: 'term-a', cwd: '/repo' } }, createResponse());
|
||||
await harness.routes.post.get('/api/terminal/create')({ body: { sessionId: 'term-b', cwd: '/other' } }, createResponse());
|
||||
|
||||
const all = createResponse();
|
||||
harness.routes.get.get('/api/terminal/sessions')({ query: {} }, all);
|
||||
expect(all.body.sessions.map((s) => s.sessionId).sort()).toEqual(['term-a', 'term-b']);
|
||||
|
||||
const scoped = createResponse();
|
||||
harness.routes.get.get('/api/terminal/sessions')({ query: { cwd: '/repo' } }, scoped);
|
||||
expect(scoped.body.sessions).toEqual([
|
||||
{ sessionId: 'term-a', cwd: '/repo', status: 'running', createdAt: expect.any(Number) },
|
||||
]);
|
||||
|
||||
const touch = createResponse();
|
||||
harness.routes.post.get('/api/terminal/touch')({ body: { sessionIds: ['term-a', 'missing', 42] } }, touch);
|
||||
expect(touch.body).toEqual({ touched: 1 });
|
||||
|
||||
const malformed = createResponse();
|
||||
harness.routes.post.get('/api/terminal/touch')({ body: {} }, malformed);
|
||||
expect(malformed.body).toEqual({ touched: 0 });
|
||||
} finally { await harness.runtime.shutdown(); }
|
||||
});
|
||||
|
||||
it('strips AppImage ARGV0 from PTY child environments', async () => {
|
||||
const previousArgv0 = process.env.ARGV0;
|
||||
process.env.ARGV0 = '/path/to/OpenChamber/OpenChamber-1.17.2-linux-x86_64.AppImage';
|
||||
|
||||
@@ -839,7 +839,7 @@ export const createUiAuth = ({
|
||||
let clientTokenResult = null;
|
||||
if (req.body?.issueClientToken === true && typeof clientAuthController?.createClient === 'function') {
|
||||
clientTokenResult = await clientAuthController.createClient({
|
||||
label: req.body?.clientLabel,
|
||||
fallbackLabel: req.body?.clientLabel,
|
||||
expiresAt: new Date(Date.now() + ttlMs).toISOString(),
|
||||
clientKind: req.body?.clientKind,
|
||||
dedupeKey: req.body?.dedupeKey,
|
||||
@@ -907,7 +907,7 @@ export const createUiAuth = ({
|
||||
let clientTokenResult = null;
|
||||
if (req.body?.issueClientToken === true && typeof clientAuthController?.createClient === 'function') {
|
||||
clientTokenResult = await clientAuthController.createClient({
|
||||
label: req.body?.clientLabel,
|
||||
fallbackLabel: req.body?.clientLabel,
|
||||
expiresAt: new Date(Date.now() + ttlMs).toISOString(),
|
||||
clientKind: req.body?.clientKind,
|
||||
dedupeKey: req.body?.dedupeKey,
|
||||
|
||||
@@ -269,7 +269,7 @@ describe('ui auth client credential seam', () => {
|
||||
token: 'client-token',
|
||||
client: {
|
||||
id: 'device-1',
|
||||
label: input.label,
|
||||
label: input.label ?? input.fallbackLabel,
|
||||
createdAt: new Date().toISOString(),
|
||||
lastUsedAt: null,
|
||||
revokedAt: null,
|
||||
@@ -295,7 +295,7 @@ describe('ui auth client credential seam', () => {
|
||||
await auth.handleSessionCreate(req, res);
|
||||
|
||||
expect(res.body.clientToken).toBe('client-token');
|
||||
expect(createClientInput.label).toBe('OpenChamber Desktop');
|
||||
expect(createClientInput.fallbackLabel).toBe('OpenChamber Desktop');
|
||||
const expiresAt = Date.parse(createClientInput.expiresAt);
|
||||
expect(expiresAt).toBeGreaterThanOrEqual(before + 122_000);
|
||||
expect(expiresAt).toBeLessThanOrEqual(Date.now() + 124_000);
|
||||
|
||||
@@ -11,6 +11,8 @@ export const createWebGitAPI = (): GitAPI => ({
|
||||
getGitDiff: gitApiHttp.getGitDiff,
|
||||
getGitFileDiff: gitApiHttp.getGitFileDiff,
|
||||
getGitRangeDiff: gitApiHttp.getGitRangeDiff,
|
||||
getGitRangeFiles: gitApiHttp.getGitRangeFiles,
|
||||
getBranchBase: gitApiHttp.getBranchBase,
|
||||
revertGitFile: gitApiHttp.revertGitFile,
|
||||
stageGitFile: gitApiHttp.stageGitFile,
|
||||
stageGitFiles: gitApiHttp.stageGitFiles,
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
restartTerminalSession,
|
||||
forceKillTerminal,
|
||||
listTerminalShells,
|
||||
listTerminalSessions,
|
||||
touchTerminalSessions,
|
||||
} from '@openchamber/ui/lib/terminalApi';
|
||||
import type {
|
||||
TerminalAPI,
|
||||
@@ -23,6 +25,14 @@ export const createWebTerminalAPI = (): TerminalAPI => ({
|
||||
return listTerminalShells();
|
||||
},
|
||||
|
||||
async listSessions(cwd: string) {
|
||||
return listTerminalSessions(cwd);
|
||||
},
|
||||
|
||||
async touchSessions(sessionIds: string[]) {
|
||||
await touchTerminalSessions(sessionIds);
|
||||
},
|
||||
|
||||
async createSession(options: CreateTerminalOptions): Promise<TerminalSession> {
|
||||
return createTerminalSession(options);
|
||||
},
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createConfiguredWebAPIs, getDesktopRelayRestoreReady } from './runtimeC
|
||||
import { registerSW } from 'virtual:pwa-register';
|
||||
|
||||
import type { RuntimeAPIs } from '@openchamber/ui/lib/api/types';
|
||||
import { resolveHostedSurface, type HostedSurface } from '@openchamber/ui/lib/runtimeSurface';
|
||||
import { resolveHostedSurface, watchHostedSurfaceViewport, type HostedSurface } from '@openchamber/ui/lib/runtimeSurface';
|
||||
import {
|
||||
isEmbeddedSessionChat,
|
||||
requestEmbeddedSessionRuntimeBootstrap,
|
||||
@@ -90,6 +90,10 @@ const start = async (): Promise<void> => {
|
||||
: null;
|
||||
window.__OPENCHAMBER_RUNTIME_APIS__ = createConfiguredWebAPIs(embeddedBootstrap);
|
||||
|
||||
// Reload into the other app shell when the viewport crosses the phone
|
||||
// threshold after boot (no-op in fixed shells and with ?surface= overrides).
|
||||
watchHostedSurfaceViewport();
|
||||
|
||||
if (hostedSurface === 'mobile') {
|
||||
const { renderMobileApp } = await import('@openchamber/ui/apps/renderMobileApp');
|
||||
renderMobileApp(window.__OPENCHAMBER_RUNTIME_APIS__);
|
||||
|
||||
@@ -108,7 +108,7 @@ describe('createConfiguredWebAPIs', () => {
|
||||
|
||||
expect(initializeRuntimeEndpoint).toHaveBeenCalledWith({
|
||||
apiBaseUrl: bootstrap.apiBaseUrl,
|
||||
runtimeKey: null,
|
||||
runtimeKey: 'host:host-1',
|
||||
});
|
||||
expect(setRuntimeBearerToken).toHaveBeenCalledWith(bootstrap.clientToken);
|
||||
expect(setRuntimeExtraHeaders).toHaveBeenCalledWith(bootstrap.runtimeHeaders);
|
||||
@@ -116,6 +116,27 @@ describe('createConfiguredWebAPIs', () => {
|
||||
expect(opencodeClient.reconnectToRuntimeBaseUrl).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('uses the configured desktop host id across changing SSH tunnel URLs', () => {
|
||||
const current = makeWindow();
|
||||
current.__OPENCHAMBER_DESKTOP_BOOT_OUTCOME__ = {
|
||||
target: 'remote',
|
||||
status: 'ok',
|
||||
hostId: 'ssh-castle',
|
||||
url: 'http://127.0.0.1:62545',
|
||||
localAvailable: true,
|
||||
};
|
||||
current.__OPENCHAMBER_API_BASE_URL__ = 'http://127.0.0.1:62545';
|
||||
current.__OPENCHAMBER_LOCAL_ORIGIN__ = 'http://127.0.0.1:3901';
|
||||
installWindow(current);
|
||||
|
||||
createConfiguredWebAPIs();
|
||||
|
||||
expect(initializeRuntimeEndpoint).toHaveBeenCalledWith({
|
||||
apiBaseUrl: 'http://127.0.0.1:62545',
|
||||
runtimeKey: 'host:ssh-castle',
|
||||
});
|
||||
});
|
||||
|
||||
test('activates an embedded relay without relying on Electron preload IPC', () => {
|
||||
const relay = {
|
||||
relayUrl: 'wss://relay.example.com',
|
||||
|
||||
@@ -2,6 +2,7 @@ import { getRuntimeExtraHeadersSync, refreshLocalRuntimeUrlAuthToken, refreshRun
|
||||
import { installRuntimeFetchBridge } from '@openchamber/ui/lib/runtime-fetch';
|
||||
import { initializeRuntimeEndpoint, switchRuntimeEndpoint } from '@openchamber/ui/lib/runtime-switch';
|
||||
import { restoreDesktopRelayRuntime } from '@openchamber/ui/lib/desktopRelayRestore';
|
||||
import { getInjectedBootOutcome } from '@openchamber/ui/lib/desktopBoot';
|
||||
import { configureRuntimeUrlResolver } from '@openchamber/ui/lib/runtime-url';
|
||||
import type { EmbeddedSessionRuntimeBootstrap } from '@openchamber/ui/components/layout/contextPanelEmbeddedChat';
|
||||
import { opencodeClient } from '@openchamber/ui/lib/opencode/client';
|
||||
@@ -45,6 +46,8 @@ export const getDesktopRelayRestoreReady = (): Promise<void> => desktopRelayRest
|
||||
|
||||
export const createConfiguredWebAPIs = (bootstrap?: EmbeddedSessionRuntimeBootstrap | null) => {
|
||||
const { apiBaseUrl, clientToken, localOrigin, runtimeHeaders, relayHostId, relay } = bootstrap ?? readRuntimeBootstrapConfig();
|
||||
const bootOutcome = bootstrap ? null : getInjectedBootOutcome();
|
||||
const desktopHostId = relayHostId || (bootOutcome?.target === 'remote' ? bootOutcome.hostId : '');
|
||||
|
||||
const urls = configureRuntimeUrlResolver({
|
||||
apiBaseUrl: apiBaseUrl || undefined,
|
||||
@@ -52,7 +55,7 @@ export const createConfiguredWebAPIs = (bootstrap?: EmbeddedSessionRuntimeBootst
|
||||
});
|
||||
initializeRuntimeEndpoint({
|
||||
apiBaseUrl,
|
||||
runtimeKey: sameOrigin(apiBaseUrl, localOrigin) ? 'local' : null,
|
||||
runtimeKey: sameOrigin(apiBaseUrl, localOrigin) ? 'local' : (desktopHostId ? `host:${desktopHostId}` : null),
|
||||
});
|
||||
setRuntimeBearerToken(clientToken || null);
|
||||
setRuntimeExtraHeaders(runtimeHeaders || null);
|
||||
|
||||
Reference in New Issue
Block a user