Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 59 additions & 4 deletions server/gjc-cli.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import os from 'node:os';
import path from 'node:path';
import { writeFileSync, unlinkSync } from 'node:fs';
import { readdirSync, writeFileSync, unlinkSync } from 'node:fs';
import { randomUUID } from 'node:crypto';

import crossSpawn from 'cross-spawn';
Expand Down Expand Up @@ -95,6 +95,56 @@ function signalGjcProcess(gjcProcess, signal) {
// GJC_CODING_AGENT_DIR, which would isolate credentials too and break the
// default model).
const DEFAULT_SESSION_DIR = path.join(os.tmpdir(), 'gjc-live-sessions');
const MAX_SESSION_LOOKUP_ENTRIES = 50_000;

function sessionExistsUnder(root, sessionId) {
if (!root || !/^[A-Za-z0-9._:-]+$/.test(sessionId || '')) {
return false;
}

const pending = [root];
let visited = 0;
while (pending.length > 0 && visited < MAX_SESSION_LOOKUP_ENTRIES) {
const directory = pending.pop();
let entries;
try {
entries = readdirSync(directory, { withFileTypes: true });
} catch {
continue;
}

for (const entry of entries) {
visited += 1;
if (visited >= MAX_SESSION_LOOKUP_ENTRIES) {
break;
}
if (entry.isSymbolicLink()) {
continue;
}
if (entry.isDirectory()) {
pending.push(path.join(directory, entry.name));
} else if (entry.isFile() && entry.name.endsWith(`_${sessionId}.jsonl`)) {
return true;
}
}
}
return false;
}

/**
* Keeps new web runs isolated, while resuming each session from the store that
* actually owns it. Native gjc TUI sessions live under gjc's normal home and
* must not be looked up in ChatMux's scratch directory.
*/
export function resolveGjcSessionDir(sessionId, sessionDir, defaultSessionDir = DEFAULT_SESSION_DIR) {
if (sessionDir) {
return sessionDir;
}
if (!sessionId) {
return defaultSessionDir;
}
return sessionExistsUnder(defaultSessionDir, sessionId) ? defaultSessionDir : undefined;
}

/**
* Builds the gjc prompt argv token. Prompts are always written to a private
Expand Down Expand Up @@ -204,7 +254,7 @@ export function spawnGjcWithRuntime(message, options = {}, writer, runtime = {})
const runPromise = new Promise((resolve, reject) => {
const { sessionId, projectPath, cwd, model, sessionDir, sessionSummary } = options;
const workingDir = cwd || projectPath || process.cwd();
const resolvedSessionDir = sessionDir || DEFAULT_SESSION_DIR;
const resolvedSessionDir = resolveGjcSessionDir(sessionId, sessionDir);

let capturedSessionId = sessionId || null;
let sessionCreatedSent = false;
Expand Down Expand Up @@ -684,11 +734,16 @@ export function spawnGjcWithRuntime(message, options = {}, writer, runtime = {})
}
};

const args = ['-p', '--mode', 'json', '--session-dir', resolvedSessionDir];
const args = ['-p', '--mode', 'json'];
if (resolvedSessionDir) {
args.push('--session-dir', resolvedSessionDir);
}
if (sessionId) {
args.push('-r', sessionId);
}
if (model) {
// `default` is ChatMux's fallback picker sentinel, not a GJC model id.
// Omitting --model lets a resumed session keep its provider-native model.
if (model && model !== 'default') {
args.push('--model', model);
}
const builtPrompt = buildPromptArg(message);
Expand Down
39 changes: 37 additions & 2 deletions server/gjc-cli.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { test } from 'node:test';
Expand All @@ -10,6 +10,7 @@ import {
abortGjcSession,
buildPromptArg,
registerGjcProcessAlias,
resolveGjcSessionDir,
spawnGjcWithRuntime,
} from './gjc-cli.js';

Expand Down Expand Up @@ -66,6 +67,35 @@ test('buildPromptArg: rejects prompts over 10 MB', () => {
);
});

test('resolveGjcSessionDir keeps fresh runs in the isolated scratch store', () => {
assert.equal(resolveGjcSessionDir(undefined, undefined, '/tmp/chatmux-gjc'), '/tmp/chatmux-gjc');
});

test('resolveGjcSessionDir keeps scratch-owned resumes in the isolated store', () => {
const root = mkdtempSync(path.join(os.tmpdir(), 'gjc-resume-root-'));
const nested = path.join(root, 'scope');
const sessionId = '019f8a0b-461f-7000-a4d5-c3a70a53bf34';
try {
mkdirSync(nested);
writeFileSync(path.join(nested, `2026-07-22_${sessionId}.jsonl`), '{}\n');
assert.equal(resolveGjcSessionDir(sessionId, undefined, root), root);
} finally {
rmSync(root, { recursive: true, force: true });
}
});

test('resolveGjcSessionDir lets native sessions use gjc default lookup', () => {
const root = mkdtempSync(path.join(os.tmpdir(), 'gjc-resume-root-'));
try {
assert.equal(
resolveGjcSessionDir('019f8a0b-461f-7000-a4d5-c3a70a53bf34', undefined, root),
undefined,
);
} finally {
rmSync(root, { recursive: true, force: true });
}
});

test('registerGjcProcessAlias: spawn handle remains abortable after provider header alias', () => {
const processes = new Map();
const child = {};
Expand Down Expand Up @@ -292,7 +322,11 @@ test('spawnGjcWithRuntime parses split CRLF NDJSON and emits normalized deltas o
const writer = createWriter();
let args: string[] = [];
let providerChecks = 0;
const run = spawnGjcWithRuntime('private prompt', { sessionId: 'resume-id' }, writer, {
const run = spawnGjcWithRuntime('private prompt', {
sessionId: 'resume-id',
sessionDir: '/tmp/gjc-test-sessions',
model: 'default',
}, writer, {
spawn(_command: string, receivedArgs: string[]) {
args = receivedArgs;
return child;
Expand All @@ -314,6 +348,7 @@ test('spawnGjcWithRuntime parses split CRLF NDJSON and emits normalized deltas o
const promptArg = args.at(-1)!;
assert.deepEqual(args.slice(0, 6), ['-p', '--mode', 'json', '--session-dir', args[4], '-r']);
assert.equal(args[6], 'resume-id');
assert.equal(args.includes('--model'), false, 'the ChatMux default sentinel is not a GJC model id');
assert.ok(existsSync(promptArg.slice(1)));

child.stdout.emit('data', '{"type":"session","id":"provider-id"}\r');
Expand Down
Loading