Skip to content
Open
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
4 changes: 2 additions & 2 deletions bridge/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion bridge/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "ftown-bridge",
"version": "0.19.1",
"version": "0.19.2",
"description": "CLI bridge for ftown — generic PTY-over-Centrifugo relay",
"type": "module",
"main": "dist/index.js",
Expand Down
3 changes: 3 additions & 0 deletions bridge/src/agent-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export {
buildCursorAgentCommand,
buildCodexCommand,
buildGrokCommand,
buildKimiCodeCommand,
} from './harness-registry.js';

export interface BuildSessionCommandInput {
Expand All @@ -22,6 +23,8 @@ export interface BuildSessionCommandInput {
command?: string;
/** Initial prompt passed as a CLI argument — avoids racing the TUI with typed input. */
initialPrompt?: string;
/** Resurrection resume — workdir-based harnesses (kimi-code) append their continue flag. */
resume?: boolean;
}

export function buildSessionCommand(input: BuildSessionCommandInput): string {
Expand Down
20 changes: 20 additions & 0 deletions bridge/src/create-ftown-session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -581,6 +581,21 @@ describe('deriveRelaunchCommand — single home of the relaunch heuristic', () =
assert.deepEqual(derived, { command: 'my-wrapper --flag', isCustom: true });
});

it('rebuilds a builder-default kimi-code stored command into the -c resume command (workdir-based)', () => {
const kimiStored = {
shellType: 'kimi-code' as const,
workingDir: '/tmp/work',
model: undefined,
claudeSessionId: undefined,
cursorSessionId: undefined,
codexSessionId: undefined,
};
const kimiDefault = buildSessionCommand({ shellType: 'kimi-code' });
const derived = deriveRelaunchCommand({ ...kimiStored, command: kimiDefault });
assert.strictEqual(derived.isCustom, false);
assert.match(derived.command, /--yolo -c$/);
});

it('KNOWN LIMITATION: a pre-model-fix claude session (stored command lacks --model) is misclassified as custom and relaunched without --model or --resume', () => {
const derived = deriveRelaunchCommand({
...stored,
Expand All @@ -604,6 +619,11 @@ describe('canResumeStoredSession — which stored sessions can resume', () => {
assert.strictEqual(canResumeStoredSession({ shellType: 'codex', claudeSessionId: 'c' }), false);
});

it('resumes kimi-code by working directory — no recorded id required', () => {
assert.strictEqual(canResumeStoredSession({ shellType: 'kimi-code' }), true);
assert.strictEqual(canResumeStoredSession({ shellType: 'kimi-code', claudeSessionId: ' ' }), true);
});

it('never resumes plain shells, opencode, or sessions with no recorded id', () => {
assert.strictEqual(canResumeStoredSession({ shellType: 'shell', claudeSessionId: 'c' }), false);
assert.strictEqual(canResumeStoredSession({ shellType: 'opencode', claudeSessionId: 'c' }), false);
Expand Down
6 changes: 6 additions & 0 deletions bridge/src/create-ftown-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,9 @@ export function deriveRelaunchCommand(session: RelaunchCommandSource): {
claudeSessionId: session.claudeSessionId,
cursorSessionId: session.cursorSessionId,
codexSessionId: session.codexSessionId,
// Workdir-based resume (kimi-code `-c`): no id to carry, so signal resume
// explicitly. Id-based harnesses ignore this and key off their id fields.
resume: true,
});
const isCustom =
Boolean(session.command) &&
Expand All @@ -301,6 +304,9 @@ export function canResumeStoredSession(
const shellType = session.shellType ?? 'claude';
if (shellType === 'cursor') return Boolean(session.cursorSessionId?.trim());
if (shellType === 'codex') return Boolean(session.codexSessionId?.trim());
// kimi-code resumes by working directory (`-c`), so it needs no captured
// session id — a stored kimi-code session is always resumable on restart.
if (shellType === 'kimi-code') return true;
return shellType !== 'shell' && shellType !== 'opencode' && Boolean(session.claudeSessionId?.trim());
}

Expand Down
2 changes: 1 addition & 1 deletion bridge/src/ftown-sessions-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ function parseLoopSchedule(args: string[], required: boolean): LoopSchedule | un

function parseLoopHarness(raw: string | undefined): LoopHarness {
const harness = (raw ?? 'claude') as LoopHarness;
if (!['claude', 'cursor', 'codex', 'grok', 'opencode', 'shell'].includes(harness)) {
if (!['claude', 'cursor', 'codex', 'grok', 'kimi-code', 'opencode', 'shell'].includes(harness)) {
throw new Error(`Invalid --shell "${raw}"`);
}
return harness;
Expand Down
48 changes: 45 additions & 3 deletions bridge/src/harness-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
LOOP_HARNESS_TYPES,
SHELL_TYPES,
WORKFLOW_SHELLS,
buildKimiCodeCommand,
harnessAcceptsPromptAsCliArg,
isLoopHarness,
isShellType,
Expand All @@ -23,7 +24,7 @@ type Equals<A, B> = [A] extends [B] ? ([B] extends [A] ? true : false) : false;
const _shellTypeIsRegistryKeys: Equals<ShellType, keyof typeof HARNESSES> = true;
const _loopHarnessUnion: Equals<
LoopHarness,
'claude' | 'cursor' | 'codex' | 'shell' | 'grok' | 'opencode'
'claude' | 'cursor' | 'codex' | 'shell' | 'grok' | 'kimi-code' | 'opencode'
> = true;
const _workflowShellUnion: Equals<
WorkflowShell,
Expand All @@ -37,7 +38,7 @@ describe('harness registry', () => {
it('contains exactly the historical ShellType set', () => {
assert.deepEqual(
[...SHELL_TYPES].sort(),
['claude', 'codex', 'cursor', 'deepseek', 'fireworks', 'grok', 'kimi', 'opencode', 'shell', 'zai'],
['claude', 'codex', 'cursor', 'deepseek', 'fireworks', 'grok', 'kimi', 'kimi-code', 'opencode', 'shell', 'zai'],
);
});

Expand Down Expand Up @@ -85,7 +86,7 @@ describe('harness registry', () => {
it('derives the historical LOOP_HARNESSES set', () => {
assert.deepEqual(
[...LOOP_HARNESS_TYPES].sort(),
['claude', 'codex', 'cursor', 'grok', 'opencode', 'shell'],
['claude', 'codex', 'cursor', 'grok', 'kimi-code', 'opencode', 'shell'],
);
});

Expand Down Expand Up @@ -145,9 +146,50 @@ describe('harness registry', () => {
assert.equal(harnessAcceptsPromptAsCliArg('opencode', {}), false);
});

it('kimi-code: never (interactive TUI takes no positional prompt)', () => {
assert.equal(harnessAcceptsPromptAsCliArg('kimi-code', {}), false);
assert.equal(
harnessAcceptsPromptAsCliArg('kimi-code', {
claudeSessionId: 'a',
cursorSessionId: 'b',
codexSessionId: 'c',
}),
false,
);
});

it('blank resume ids do not suppress the CLI arg (trim semantics preserved)', () => {
assert.equal(harnessAcceptsPromptAsCliArg('claude', { claudeSessionId: ' ' }), true);
assert.equal(harnessAcceptsPromptAsCliArg('cursor', { cursorSessionId: '' }), true);
});
});
});

describe('buildKimiCodeCommand', () => {
const KIMI = '"$HOME/.kimi-code/bin/kimi"';

it('no-resume output is unchanged (frozen)', () => {
assert.equal(buildKimiCodeCommand({}), `${KIMI} --yolo`);
assert.equal(buildKimiCodeCommand({ model: 'k2' }), `${KIMI} --yolo -m 'k2'`);
});

it('resume appends -c after --yolo', () => {
assert.equal(buildKimiCodeCommand({ resume: true }), `${KIMI} --yolo -c`);
});

it('resume keeps the model so the resumed session retains it', () => {
assert.equal(
buildKimiCodeCommand({ resume: true, model: 'k2' }),
`${KIMI} --yolo -c -m 'k2'`,
);
});

it('registry kimi-code entry threads the resume flag through', () => {
assert.equal(HARNESSES['kimi-code'].buildCommand({}), `${KIMI} --yolo`);
assert.equal(HARNESSES['kimi-code'].buildCommand({ resume: true }), `${KIMI} --yolo -c`);
assert.equal(
HARNESSES['kimi-code'].buildCommand({ resume: true, model: 'k2' }),
`${KIMI} --yolo -c -m 'k2'`,
);
});
});
33 changes: 33 additions & 0 deletions bridge/src/harness-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ export interface BuildCommandInput {
codexSessionId?: string;
/** Initial prompt passed as a CLI argument — avoids racing the TUI with typed input. */
initialPrompt?: string;
/**
* Relaunch is a resurrection resume. Harnesses that resume by working
* directory rather than by a captured session id (kimi-code, via `-c`)
* consult this flag; id-based harnesses ignore it and use their id fields.
*/
resume?: boolean;
}

export interface HarnessSpec {
Expand Down Expand Up @@ -131,6 +137,25 @@ export function buildGrokCommand(options: {
return parts.join(' ');
}

export function buildKimiCodeCommand(options: { model?: string; resume?: boolean }): string {
// Absolute path: the kimi-code installer adds ~/.kimi-code/bin to PATH only
// via .zshrc (interactive), but ftown launches agents with `zsh -l -c`
// (non-interactive login), which does NOT source .zshrc — so a bare `kimi`
// would fail to resolve. --yolo auto-approves all tool actions for unattended runs.
const parts = ['"$HOME/.kimi-code/bin/kimi"', '--yolo'];
// Resurrection resume: `-c` continues the previous session for the working
// directory. ftown launches each kimi-code session with a stable workingDir,
// so `-c` on relaunch resumes exactly the conversation that ran there — no
// captured session id needed.
if (options.resume) {
parts.push('-c');
}
if (options.model?.trim()) {
parts.push('-m', shellQuote(options.model.trim()));
}
return parts.join(' ');
}

/** claude CLI launch — shared by 'claude' and every claude-rebadged provider flavor. */
function buildClaudeCommand(input: BuildCommandInput): string {
const parts = ['claude', '--allow-dangerously-skip-permissions'];
Expand Down Expand Up @@ -231,6 +256,14 @@ export const HARNESSES = {
validForLoop: true,
validForWorkflow: true,
},
'kimi-code': {
// Own binary (absolute path); interactive TUI, no positional prompt.
buildCommand: (input) => buildKimiCodeCommand({ model: input.model, resume: input.resume }),
hooked: false,
promptAsCliArg: false,
validForLoop: true,
validForWorkflow: false,
},
zai: PROVIDER_FLAVOR_SPEC,
kimi: PROVIDER_FLAVOR_SPEC,
deepseek: PROVIDER_FLAVOR_SPEC,
Expand Down
Loading
Loading