Merge #650: jcode as a run mode

Conflict in skills/codeman/SKILL.md (and its plugin mirror): the list of
modes with neither stop nor blocked gained jcode here and the omp
exception from #798 on the release branch; both are kept.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-11-11 20:27:26 +01:00
commit d45b1ff248
66 files changed
+1304 -127

No files matched your search

+2 -1
View File
@@ -37,6 +37,7 @@ describe('per-mode capability predicates', () => {
['deepseek', true, false, true],
['omp', true, false, false],
['copilot', true, false, false],
['jcode', true, false, false],
] as Array<[SessionMode, boolean, boolean, boolean]>)(
'%s: external=%s altScreenStrip=%s hooks=%s',
(mode, external, altScreen, hooks) => {
@@ -49,7 +50,7 @@ describe('per-mode capability predicates', () => {
it('covers every enabled mode (sanity)', () => {
// If a CLI is added without a row above, this fails rather than the table silently
// describing a subset of reality.
expect(MODES.length).toBe(11);
expect(MODES.length).toBe(12);
});
it('keeps the three predicates genuinely distinct', () => {
+4 -1
View File
@@ -97,6 +97,7 @@ const ALLOWED_BRANCHES: Record<string, Allowance> = {
"web/routes/session-routes.ts::mode === 'grok'": allow(2, 'legacy <Mode>Config plumbing'),
"web/routes/session-routes.ts::mode === 'copilot'": allow(2, 'legacy <Mode>Config plumbing'),
"web/routes/session-routes.ts::mode === 'deepseek'": allow(2, 'legacy <Mode>Config plumbing'),
"web/routes/session-routes.ts::mode === 'jcode'": allow(2, 'legacy <Mode>Config plumbing'),
"web/server.ts::mode === 'opencode'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
"web/server.ts::mode === 'codex'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
"web/server.ts::mode === 'gemini'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
@@ -106,6 +107,7 @@ const ALLOWED_BRANCHES: Record<string, Allowance> = {
"web/server.ts::mode === 'copilot'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
"web/server.ts::mode === 'deepseek'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
"web/server.ts::mode === 'omp'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
"web/server.ts::mode === 'jcode'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
// --- Claude's remote/docker command construction ---
"tmux-manager.ts::mode === 'claude'": allow(
@@ -174,7 +176,7 @@ const ALLOWED_BRANCHES: Record<string, Allowance> = {
'chain (see the opencode entry below)'
),
// ⚠️ Scaffolded-case hooks. This chain excludes seven CLIs but NOT `deepseek`, while its
// ⚠️ Scaffolded-case hooks. This chain excludes eight CLIs but NOT `deepseek`, while its
// own comment says DeepSeek uses its own system — so a scaffolded deepseek case gets a
// Claude hooks block written into it. That inconsistency is UPSTREAM's and predates this
// change; expressing the chain as a capability would have to pick a side and would
@@ -189,6 +191,7 @@ const ALLOWED_BRANCHES: Record<string, Allowance> = {
"web/routes/session-routes.ts::mode !== 'antigravity'": allow(1, 'scaffolded-case hooks (see the opencode entry)'),
"web/routes/session-routes.ts::mode !== 'pi'": allow(1, 'scaffolded-case hooks (see the opencode entry)'),
"web/routes/session-routes.ts::mode !== 'grok'": allow(1, 'scaffolded-case hooks (see the opencode entry)'),
"web/routes/session-routes.ts::mode !== 'jcode'": allow(1, 'scaffolded-case hooks (see the opencode entry)'),
};
/** Every stock CLI id, derived rather than restated so a new entry is covered automatically. */
+3 -3
View File
@@ -645,12 +645,12 @@ describe('capability shapes', () => {
});
it('declares transcriptPageKeys for exactly the CLIs measured to page on PageUp/PageDown', () => {
// claude (repaint mode), codex, opencode and copilot (all on the alternate screen) were
// measured paging their own transcript on PageUp/PageDown; no other stock entry has been.
// claude (repaint mode), codex, opencode, copilot and jcode (all on the alternate screen)
// were measured paging their own transcript on PageUp/PageDown; no other stock entry has been.
// pi pages too when fullscreen, and stays out on purpose: in `tuiMode: regular` the same keys
// move the caret of a multi-line draft.
const paging = STOCK_CLIS.filter((e) => e.capabilities.transcriptPageKeys === true).map((e) => e.id as string);
expect(paging.sort()).toEqual(['claude', 'codex', 'copilot', 'opencode']);
expect(paging.sort()).toEqual(['claude', 'codex', 'copilot', 'jcode', 'opencode']);
});
});
+21
View File
@@ -326,6 +326,27 @@ describe('grok', () => {
});
});
describe('jcode', () => {
const jc = (jcodeConfig?: SpawnBridgeOptions['jcodeConfig']) => render({ mode: 'jcode', sessionId: SID, jcodeConfig });
it('spawns bare by default: no bypass flag exists to send', () => {
expect(jc()).toBe('jcode');
});
it('passes --model and --resume', () => {
expect(jc({ model: 'claude-opus-4-8' })).toBe('jcode --model claude-opus-4-8');
expect(jc({ resumeSessionId: 'session_fox_1792160401272_f5b47ce47d631006' })).toBe(
'jcode --resume session_fox_1792160401272_f5b47ce47d631006'
);
expect(jc({ model: 'gpt-5.5', resumeSessionId: 'fox' })).toBe('jcode --model gpt-5.5 --resume fox');
});
it('drops a resume value that would read as a flag of its own', () => {
// --resume's value is optional in jcode, so `--resume --no-update` is two flags.
expect(jc({ resumeSessionId: '--no-update' })).toBe('jcode');
});
});
describe('deepseek', () => {
const ds = (deepSeekConfig?: SpawnBridgeOptions['deepSeekConfig']) =>
render({ mode: 'deepseek', sessionId: SID, deepSeekConfig });
+4
View File
@@ -93,6 +93,9 @@ const LITERAL_SEARCH_PATHS: Record<string, string[]> = {
'$HOME/.npm-global/bin/copilot',
'$HOME/bin/copilot',
],
// Not from a hand-written array: jcode arrived after the generated block replaced them, so
// this is the registry's own list (the installer's ~/.local/bin target, then the image's).
JCODE: ['$HOME/.local/bin/jcode', '/usr/local/bin/jcode', '$HOME/bin/jcode'],
};
/** Array prefix in `install.sh` -> registry id, for the two that differ. */
@@ -107,6 +110,7 @@ const ARRAY_PREFIX_TO_CLI_ID: Record<string, string> = {
ANTIGRAVITY: 'antigravity',
OMP: 'omp',
COPILOT: 'copilot',
JCODE: 'jcode',
};
/**
+259
View File
@@ -0,0 +1,259 @@
/**
* @fileoverview jcode (1jehuang/jcode) as a run mode: its wire config, its spawn command,
* its capability flags, and the screen-reading patterns that tell a working jcode pane from
* one at rest.
*
* The pane fixtures are verbatim captures (`tmux capture-pane -p`, rows shortened to their
* tail) and verbatim tmux CLIENT stream chunks, both from a live jcode v0.98.1 pane driven
* through real turns on 2026-10-16: a plain reply, a bash tool call, and a streamed answer.
* The client stream matters because that is what Codeman's PTY reads, and tmux redraws only
* the cells that changed, so the composer glyph has to survive that diffing.
*
* Port: none (pure functions plus a Session with a scripted fake pane).
*/
import { describe, expect, it, afterEach, vi } from 'vitest';
import { CreateSessionSchema, QuickStartSchema } from '../src/web/schemas.js';
import { buildSpawnCommand } from '../src/tmux-manager.js';
import { defaultDockerCommandForMode } from '../src/docker-hosts.js';
import { defaultRemoteCommandForMode, remoteLoginShellCommand } from '../src/remote-hosts.js';
import { Session, isExternalCliMode, isAltScreenStripMode } from '../src/session.js';
import { hooksAvailableForMode } from '../src/web/session-wait-registry.js';
import { getCli } from '../src/config/cli-registry/index.js';
import { compileVersionRegex } from '../src/config/cli-registry/patterns.js';
import { promptStillInComposer } from '../src/session-submit-verifier.js';
import { isExternalCliTranscriptMode } from '../src/web/response-viewer-transcript.js';
const jcode = getCli('jcode');
const workDetect = jcode?.capabilities.workDetect;
const workingLine = compileVersionRegex(workDetect?.workingLine ?? '');
/** The composer and the status bar under it, as every capture ends (bar shortened). */
const FOOTER_REST = '\n5>\n …/scratchpad/jwork master ?1 17k/252k ▰▱▱▱▱▱▱▱▱▱ 7% qwen5090 qwen3.8 27b\n';
const FOOTER_BUSY = '\n5…\n …/scratchpad/jwork master ?1 17k/252k ▰▱▱▱▱▱▱▱▱▱ 7% qwen5090 qwen3.8 27b\n';
const PROMPT_ROW = ' 4› Use your bash tool to run exactly: sleep 8; echo slept. Then write a 40 word paragraph.\n\n';
/** One status row per phase a turn goes through, each verbatim from a capture. */
const WORKING_ROWS = [
'⠋ sending… 0s',
'⠦ thinking… 2s · https',
'⠼ waiting for response… 3s · https',
'⠧ running bash · Run user-requested sleep probe · https · 2s · Alt+B bg',
'⠙ 11s · 182.7 tps · ↑17k ↓156 · https',
];
const atRest =
`${PROMPT_ROW} The command ran and printed slept after 8 seconds.\n\n` +
` Donuts are ring-shaped treats, glazed with sugary sweetness.\n` +
// The turn's summary row: a time, but no spinner, so it must never read as work.
` 21s · 199.6 tps · ↑17k ↓2k\n${FOOTER_REST}`;
/**
* The tmux client stream (what Codeman's PTY reads). Mid-turn it is the spinner cell being
* repainted on its own; the turn's end repaints the composer row, and the only cell there
* that changes is `…` turning into `>` (the prompt number is not resent).
*/
const SPINNER_CHUNK = '\x1b[38;1H\x1b[38;5;114m⠏\x1b[39;4H\x1b(B\x1b[m';
const TURN_END_CHUNK =
'\x1b[40;2H\x1b[38;5;251mDONE\x1b[41;2H\x1b[39m \x1b[38;5;239m10s · 189.1 tps · ↑16k ↓15\r\n' +
'\x1b[39m \x1b[43;2H\x1b[38;5;111m> \x1b(B\x1b[m';
describe('jcode wire config', () => {
it('accepts a model and a resume id or short name on both create paths', () => {
const created = CreateSessionSchema.parse({
workingDir: '/tmp',
mode: 'jcode',
jcodeConfig: { model: 'claude-opus-4-8', resumeSessionId: 'session_fox_1792160401272_f5b47ce47d631006' },
});
expect(created.jcodeConfig?.resumeSessionId).toBe('session_fox_1792160401272_f5b47ce47d631006');
const quick = QuickStartSchema.parse({ caseName: 'jc', mode: 'jcode', jcodeConfig: { resumeSessionId: 'fox' } });
expect(quick.jcodeConfig?.resumeSessionId).toBe('fox');
});
it('refuses a resume value that would parse as a flag, and an unsafe model', () => {
// --resume takes an OPTIONAL value, so `--resume --no-update` would read as two flags.
for (const resumeSessionId of ['--no-update', '-x', 'fox; rm -rf ~', 'a b']) {
expect(
CreateSessionSchema.safeParse({ workingDir: '/tmp', mode: 'jcode', jcodeConfig: { resumeSessionId } }).success,
resumeSessionId
).toBe(false);
}
expect(
CreateSessionSchema.safeParse({ workingDir: '/tmp', mode: 'jcode', jcodeConfig: { model: 'a`b' } }).success
).toBe(false);
});
it('admits no JCODE_* env override', () => {
// Most of that namespace is read by jcode's shared daemon, hooks and command paths
// included, so a per-session value would either do nothing or reach every session.
expect(
CreateSessionSchema.safeParse({ workingDir: '/tmp', mode: 'jcode', envOverrides: { JCODE_HOME: '/tmp/x' } })
.success
).toBe(false);
expect(jcode?.env.allowedPrefixes).toEqual([]);
});
});
describe('jcode spawn command', () => {
const spawn = (jcodeConfig?: Parameters<typeof buildSpawnCommand>[0]['jcodeConfig']) =>
buildSpawnCommand({ mode: 'jcode', sessionId: 'abc12345', jcodeConfig });
it('spawns bare, then with --model and --resume when asked', () => {
expect(spawn()).toBe('jcode');
expect(spawn({ model: 'gpt-5.5' })).toBe('jcode --model gpt-5.5');
expect(spawn({ resumeSessionId: 'session_fox_1792160401272_f5b47ce47d631006' })).toBe(
'jcode --resume session_fox_1792160401272_f5b47ce47d631006'
);
expect(spawn({ model: 'gpt-5.5', resumeSessionId: 'fox' })).toBe('jcode --model gpt-5.5 --resume fox');
});
it('drops a value the schema would have refused rather than emit it', () => {
// Defence in depth behind the schema: the argv engine re-checks every token.
expect(spawn({ resumeSessionId: '--no-update' })).toBe('jcode');
expect(spawn({ model: 'a`b' })).toBe('jcode');
});
it('has remote and docker pane commands', () => {
expect(defaultDockerCommandForMode('jcode')).toBe('exec jcode');
expect(defaultRemoteCommandForMode('jcode')).toBe(remoteLoginShellCommand('jcode'));
});
});
describe('jcode capabilities', () => {
it('is an external CLI with no hooks, its own strip, and no agent skill', () => {
expect(isExternalCliMode('jcode')).toBe(true);
expect(isAltScreenStripMode('jcode')).toBe(false);
expect(hooksAvailableForMode('jcode')).toBe(false);
expect(jcode?.capabilities.altScreen).toBe('strip-mux-and-mouse');
expect(jcode?.capabilities.transcriptPageKeys).toBe(true);
// Its tools run in the shared daemon, under whichever session started it, so an
// injected skill would act as that session.
expect(jcode?.capabilities.agentSkillInjection).toBe(false);
expect(jcode?.capabilities.privilegedParams).toEqual([]);
});
it('anchors its version probe on the `jcode v` banner', () => {
const version = jcode?.discovery.version;
expect(version?.requireVersionMatch).toBe(true);
const re = compileVersionRegex(version?.regex ?? '');
expect(re?.exec('jcode v0.98.1 (1c7426eb1)')?.[1]).toBe('0.98.1');
// The unrelated npm package of the same name, or any other `jcode`, is absent.
expect(re?.exec('1.0.0')).toBeNull();
});
it('is never segmented off its pane by the response viewer', () => {
expect(isExternalCliTranscriptMode('jcode')).toBe(false);
});
});
describe("jcode's work-detection descriptor", () => {
it('compiles through the regex guard', () => {
expect(workingLine).not.toBeNull();
});
it('matches every status row a turn shows, in a full capture', () => {
for (const row of WORKING_ROWS) {
expect(workingLine!.test(`${PROMPT_ROW}${row}${FOOTER_BUSY}`), row).toBe(true);
}
});
it('reads past a minute and an hour, a dropped network, and a centred layout', () => {
expect(workingLine!.test(`${PROMPT_ROW}⠸ thinking… 1m 3s · https${FOOTER_BUSY}`)).toBe(true);
expect(workingLine!.test(`${PROMPT_ROW}⠸ running bash · build · 1h 2m · Alt+B bg${FOOTER_BUSY}`)).toBe(true);
expect(
workingLine!.test(`${PROMPT_ROW}↻ network disconnected, waiting to retry · sse · 5s${FOOTER_BUSY}`)
).toBe(true);
expect(workingLine!.test(`${PROMPT_ROW} ⠸ thinking… 4s${FOOTER_BUSY}`)).toBe(true);
});
it('does not match a pane at rest, its summary row, or a braille glyph in the transcript', () => {
expect(workingLine!.test(atRest)).toBe(false);
expect(workingLine!.test(`${PROMPT_ROW} the spinner looked like ⠋ for 3s\n${FOOTER_REST}`)).toBe(false);
});
it('arms the idle check from the turn-ending repaint, and nothing mid-turn carries the glyph', () => {
expect(TURN_END_CHUNK).toContain(workDetect!.promptGlyph);
expect(SPINNER_CHUNK).not.toContain(workDetect!.promptGlyph);
});
it('leaves the submit verifier inert: no screen row starts with the glyph', () => {
// The composer row is `<n>> …`, so the verifier finds no composer line and never presses
// Enter on its own (jcode has no early-Enter defect for it to work around).
const typed = `${PROMPT_ROW}${FOOTER_REST.replace('5>', '5> say ok')}`;
expect(promptStillInComposer(typed, 'say ok', workDetect!.promptGlyph)).toBeUndefined();
expect(promptStillInComposer(atRest, 'say ok', workDetect!.promptGlyph)).toBeUndefined();
});
});
describe('jcode interactive idle detection', () => {
afterEach(() => {
vi.useRealTimers();
});
type SessionInternals = {
_handleTerminalOutput(data: string): void;
_detectInteractiveActivity(data: string): void;
};
function feed(session: Session, data: string): void {
const internals = session as unknown as SessionInternals;
internals._handleTerminalOutput(data);
internals._detectInteractiveActivity(data);
}
function withFakePane(read: () => string): Session {
const mux = {
isAvailable: () => true,
capturePaneText: () => read(),
} as unknown as NonNullable<ConstructorParameters<typeof Session>[0]>['mux'];
return new Session({
workingDir: '/tmp',
mode: 'jcode',
mux,
muxSession: { muxName: 'codeman-test', sessionId: 'test', createdAt: Date.now() },
} as ConstructorParameters<typeof Session>[0]);
}
/** The spinner cell repainted every 80 ms, as jcode's ~12 fps indicator does. */
function spin(session: Session, ms: number): void {
for (let t = 0; t < ms; t += 80) {
feed(session, SPINNER_CHUNK);
vi.advanceTimersByTime(80);
}
}
it('marks a turn working off its spinner and ends it on the composer repaint', () => {
vi.useFakeTimers();
let screen = `${PROMPT_ROW}${WORKING_ROWS[3]}${FOOTER_BUSY}`;
const session = withFakePane(() => screen);
const events: string[] = [];
session.on('working', () => events.push('working'));
session.on('idle', () => events.push('idle'));
spin(session, 8000);
expect(events).toEqual(['working']);
expect(session.status).toBe('busy');
screen = atRest;
feed(session, TURN_END_CHUNK);
vi.advanceTimersByTime(20_000);
expect(events).toEqual(['working', 'idle']);
expect(session.status).toBe('idle');
});
it('does not end a turn while the status row is still on screen', () => {
vi.useFakeTimers();
// A quiet pane whose screen still shows the spinner row (a stalled stream): the
// screen says working, so silence alone must not conclude idle.
const screen = `${PROMPT_ROW}${WORKING_ROWS[2]}${FOOTER_BUSY}`;
const session = withFakePane(() => screen);
const events: string[] = [];
session.on('working', () => events.push('working'));
session.on('idle', () => events.push('idle'));
spin(session, 4000);
feed(session, TURN_END_CHUNK);
vi.advanceTimersByTime(20_000);
expect(events).toEqual(['working']);
expect(session.status).toBe('busy');
});
});
+2
View File
@@ -35,6 +35,7 @@ it('pins every remote pane command', () => {
deepseek: remoteLoginShellCommand('dsh'),
omp: remoteLoginShellCommand('omp'),
copilot: remoteLoginShellCommand('copilot'),
jcode: remoteLoginShellCommand('jcode'),
};
for (const [mode, want] of Object.entries(expected)) {
expect(defaultRemoteCommandForMode(mode as SessionMode), mode).toBe(want);
@@ -55,6 +56,7 @@ it('pins every in-container pane command', () => {
deepseek: 'exec dsh',
omp: 'exec omp',
copilot: 'exec copilot',
jcode: 'exec jcode',
};
for (const [mode, want] of Object.entries(expected)) {
expect(defaultDockerCommandForMode(mode as SessionMode), mode).toBe(want);
+1
View File
@@ -470,6 +470,7 @@ describe('mobile overview run picker (CLI availability gating)', () => {
'deepseek',
'omp',
'copilot',
'jcode',
'shell',
]);
});
+45 -1
View File
@@ -15,7 +15,13 @@
*/
import { describe, expect, it, vi } from 'vitest';
import { collectDescendants, PROC_WALK_MAX_DEPTH, PROC_WALK_MAX_NODES } from '../src/proc-tree.js';
import {
collectDescendants,
descendantsInPaneGroup,
PROC_WALK_MAX_DEPTH,
PROC_WALK_MAX_NODES,
} from '../src/proc-tree.js';
import { STOCK_CLIS } from '../src/config/cli-registry/stock.js';
/** Build a parent→children map from `[parent, child]` pairs. */
function tree(pairs: [number, number][]): Map<number, number[]> {
@@ -198,3 +204,41 @@ describe('TmuxManager uses the bounded walk', () => {
expect(out.length).toBeLessThanOrEqual(PROC_WALK_MAX_NODES);
});
});
describe('descendantsInPaneGroup (a shared daemon outlives the pane that started it)', () => {
// The jcode shape, measured: pane 100 runs the client (group 100); the client spawned the
// daemon 200, which setsid()'d into group 200 and runs tools 300 and 301 for EVERY jcode
// session; 110 is an ordinary child of the client.
const descendants = collectDescendants(
100,
tree([
[100, 110],
[100, 200],
[200, 300],
[200, 301],
])
);
const groups = new Map([
[100, 100],
[110, 100],
[200, 200],
[300, 200],
[301, 200],
]);
it("keeps the pane's own group and spares the daemon with its whole subtree", () => {
expect(descendants.sort()).toEqual([110, 200, 300, 301]);
expect(descendantsInPaneGroup(descendants, groups, 100)).toEqual([110]);
});
it('kills a pid whose group could not be read (it most likely exited already)', () => {
const partial = new Map(groups);
partial.delete(110);
expect(descendantsInPaneGroup(descendants, partial, 100)).toEqual([110]);
});
it('is declared by jcode alone, so every other CLI keeps the full descendant kill', () => {
const declaring = STOCK_CLIS.filter((e) => e.capabilities.sharedDaemon === true).map((e) => e.id as string);
expect(declaring).toEqual(['jcode']);
});
});
+3
View File
@@ -242,6 +242,9 @@ describe('WebServer.renderIndexHtml', () => {
deepseekBinary: false,
omp: true,
copilot: true,
// No per-CLI resolver module: the registry's generic one answers, and it resolves
// nothing under vitest (its probes would otherwise run whatever `jcode` is on PATH).
jcode: false,
cloudflared: true,
git: true,
shell: true,
@@ -215,6 +215,24 @@ describe('GET /api/sessions/:id/last-response — external CLI panes', () => {
});
});
it('answers jcode with an honest empty, never a segmented pane', async () => {
// jcode is a repainting alternate-screen TUI: segmenting its stream served the
// welcome banner as "the answer" (measured on a live pane), and the Claude scan
// below the external branch could adopt a Claude conversation from the same folder.
// It has no transcript reader yet, so "nothing said yet" is the only honest reply.
session.mode = 'jcode';
session.terminalBuffer = PANE;
const { res, body } = await lastResponse();
expect(res.statusCode).toBe(200);
expect(body.data.text).toBe('');
expect(body.data.messages).toBeUndefined();
const full = await lastResponse(true);
expect(full.body.data.text).toBe('');
expect(full.body.data.messages).toEqual([]);
});
it('leaves Claude mode on the Claude transcript path', async () => {
// Regression guard: a claude pane must NOT be segmented off its terminal
// buffer, or a real transcript would be shadowed by scraped pane text.