Skip to content

Commit 611df95

Browse files
committed
feat: add support for using omp as an agent
1 parent 6298049 commit 611df95

17 files changed

Lines changed: 857 additions & 46 deletions

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ Inside a project's Studio, the conversation, generated files, and live preview s
131131
| [Kimi CLI](https://github.com/MoonshotAI/kimi-cli) | ✅ Supported | `od mcp install kimi` |
132132
| [Kiro](https://kiro.dev) | ✅ Supported | `od mcp install kiro` |
133133
| [Pi Agent](https://github.com/badlogic/pi-mono) | ✅ Supported | `od mcp install pi` |
134+
| [Oh My Pi](https://github.com/can1357/oh-my-pi) | ✅ Supported | `od mcp install omp` |
134135
| [Mistral Vibe CLI](https://github.com/mistralai/mistral-vibe) | ✅ Supported | `od mcp install vibe` |
135136
| [Hermes Agent](https://github.com/nousresearch/hermes-agent) | ✅ Supported | `od mcp install hermes` |
136137

apps/daemon/src/agent-protocol/index.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,12 @@ export {
1111
detectAcpModels,
1212
attachAcpSession,
1313
} from './acp/index.js';
14-
export { mapPiRpcEvent, attachPiRpcSession, parsePiModels } from './pi-rpc/index.js';
14+
export {
15+
mapPiRpcEvent,
16+
attachPiRpcSession,
17+
parsePiModels,
18+
piSessionsDir,
19+
DEFAULT_PI_SESSION_DIR_NAME,
20+
type PiRpcResumeCommand,
21+
} from './pi-rpc/index.js';
1522
export * from './dsh-profile/index.js';

apps/daemon/src/agent-protocol/pi-rpc/index.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,10 @@
44
* (session lifecycle), and `parsePiModels` (model list parser).
55
*/
66
export { mapPiRpcEvent } from './events.js';
7-
export { attachPiRpcSession } from './session.js';
7+
export {
8+
attachPiRpcSession,
9+
piSessionsDir,
10+
DEFAULT_PI_SESSION_DIR_NAME,
11+
type PiRpcResumeCommand,
12+
} from './session.js';
813
export { parsePiModels } from './models.js';

apps/daemon/src/agent-protocol/pi-rpc/session.ts

Lines changed: 107 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,20 @@ export type PiImagePayload = {
2121
};
2222
/** Generic parameter bag for a pi RPC command written to child stdin. */
2323
export type PiRpcParams = JsonRecord;
24+
/**
25+
* Which RPC command continues a prior conversation.
26+
*
27+
* - `new-session-parent` (default): `new_session { parentSession }`. Upstream pi
28+
* loads the parent transcript into the freshly minted session.
29+
* - `switch-session`: `switch_session { sessionPath }`. Oh My Pi's fork narrowed
30+
* `new_session`'s `parentSession` to a lineage-only header field that records
31+
* provenance WITHOUT replaying the parent's entries, so resuming through it
32+
* silently yields an empty context. `switch_session` reopens the transcript in
33+
* place and keeps appending to the same `.jsonl`.
34+
*/
35+
export type PiRpcResumeCommand = 'new-session-parent' | 'switch-session';
36+
/** Directory under the working directory holding a runtime's session `.jsonl` files. */
37+
export const DEFAULT_PI_SESSION_DIR_NAME = '.pi';
2438
/** Options for `attachPiRpcSession`. All fields map directly to the pi RPC protocol. */
2539
export type PiRpcSessionOptions = {
2640
child: ChildProcess;
@@ -31,6 +45,8 @@ export type PiRpcSessionOptions = {
3145
imagePaths?: string[];
3246
uploadRoot?: string;
3347
parentSession?: string;
48+
resumeCommand?: PiRpcResumeCommand;
49+
sessionDirName?: string;
3450
};
3551
/** Handle returned by `attachPiRpcSession` for querying run state and requesting abort. */
3652
export type PiRpcSession = {
@@ -94,15 +110,34 @@ export function replyExtensionUi(writable: Writable, raw: JsonRecord): void {
94110
/** Snapshot of `.pi/sessions/` file metadata taken before a prompt is sent. */
95111
export type PiSessionFileSnapshot = Map<string, { mtimeMs: number; size: number }>;
96112
/**
97-
* Reads `.pi/sessions/*.jsonl` entries from the given working directory,
98-
* returning file paths with their mtime and size. Returns an empty array
99-
* when the directory is absent, empty, or unreadable.
113+
* Absolute path of the session directory a pi-family runtime writes into,
114+
* given its working directory. Each runtime owns its own directory name so two
115+
* adapters (pi and Oh My Pi) running against the same project cannot see each
116+
* other's transcripts as "the file this run changed".
100117
*
101-
* @param cwd - Absolute path to the pi working directory; may be undefined.
118+
* @param cwd - Absolute path to the runtime's working directory.
119+
* @param sessionDirName - Directory under `cwd`; defaults to pi's `.pi`.
102120
*/
103-
export function readPiSessionFiles(cwd: string | undefined): Array<{ path: string; mtimeMs: number; size: number }> {
121+
export function piSessionsDir(
122+
cwd: string,
123+
sessionDirName: string = DEFAULT_PI_SESSION_DIR_NAME,
124+
): string {
125+
return path.join(cwd, sessionDirName, 'sessions');
126+
}
127+
/**
128+
* Reads `<sessionDirName>/sessions/*.jsonl` entries from the given working
129+
* directory, returning file paths with their mtime and size. Returns an empty
130+
* array when the directory is absent, empty, or unreadable.
131+
*
132+
* @param cwd - Absolute path to the pi working directory; may be undefined.
133+
* @param sessionDirName - Directory under `cwd`; defaults to pi's `.pi`.
134+
*/
135+
export function readPiSessionFiles(
136+
cwd: string | undefined,
137+
sessionDirName: string = DEFAULT_PI_SESSION_DIR_NAME,
138+
): Array<{ path: string; mtimeMs: number; size: number }> {
104139
if (typeof cwd !== 'string' || cwd.length === 0) return [];
105-
const sessionsDir = path.join(cwd, '.pi', 'sessions');
140+
const sessionsDir = piSessionsDir(cwd, sessionDirName);
106141
let entries: fs.Dirent[];
107142
try {
108143
entries = fs.readdirSync(sessionsDir, { withFileTypes: true });
@@ -123,33 +158,39 @@ export function readPiSessionFiles(cwd: string | undefined): Array<{ path: strin
123158
return files;
124159
}
125160
/**
126-
* Takes a before-snapshot of `.pi/sessions/` to enable changed-file detection
127-
* after the prompt completes.
161+
* Takes a before-snapshot of the session directory to enable changed-file
162+
* detection after the prompt completes.
128163
*
129-
* @param cwd - Absolute path to the pi working directory; may be undefined.
164+
* @param cwd - Absolute path to the pi working directory; may be undefined.
165+
* @param sessionDirName - Directory under `cwd`; defaults to pi's `.pi`.
130166
*/
131-
export function snapshotPiSessionFiles(cwd: string | undefined): PiSessionFileSnapshot {
167+
export function snapshotPiSessionFiles(
168+
cwd: string | undefined,
169+
sessionDirName: string = DEFAULT_PI_SESSION_DIR_NAME,
170+
): PiSessionFileSnapshot {
132171
const snapshot: PiSessionFileSnapshot = new Map();
133-
for (const file of readPiSessionFiles(cwd)) {
172+
for (const file of readPiSessionFiles(cwd, sessionDirName)) {
134173
snapshot.set(file.path, { mtimeMs: file.mtimeMs, size: file.size });
135174
}
136175
return snapshot;
137176
}
138177
/**
139-
* Compares the current `.pi/sessions/` directory against a before-snapshot
140-
* and returns the path of the single changed file. Returns `null` when zero
141-
* or more than one file changed — concurrent pi processes are detected this
142-
* way to avoid associating the wrong session with this run.
178+
* Compares the current session directory against a before-snapshot and returns
179+
* the path of the single changed file. Returns `null` when zero or more than
180+
* one file changed — concurrent pi processes are detected this way to avoid
181+
* associating the wrong session with this run.
143182
*
144-
* @param cwd - Absolute path to the pi working directory; may be undefined.
145-
* @param before - Snapshot taken before the prompt was sent.
183+
* @param cwd - Absolute path to the pi working directory; may be undefined.
184+
* @param before - Snapshot taken before the prompt was sent.
185+
* @param sessionDirName - Directory under `cwd`; defaults to pi's `.pi`.
146186
* @returns Absolute path of the changed session file, or `null`.
147187
*/
148188
export function resolveSessionPathChangedSince(
149189
cwd: string | undefined,
150190
before: PiSessionFileSnapshot,
191+
sessionDirName: string = DEFAULT_PI_SESSION_DIR_NAME,
151192
): string | null {
152-
const changed = readPiSessionFiles(cwd).filter((file) => {
193+
const changed = readPiSessionFiles(cwd, sessionDirName).filter((file) => {
153194
const previous = before.get(file.path);
154195
return !previous || file.mtimeMs > previous.mtimeMs || file.size !== previous.size;
155196
});
@@ -159,10 +200,12 @@ export function resolveSessionPathChangedSince(
159200
* Attaches the daemon's run lifecycle to an already-spawned `pi --mode rpc` child process.
160201
*
161202
* Responsibilities:
162-
* - Sends a `new_session` RPC command (with `parentSession`) before the prompt when
163-
* resuming a prior conversation, waiting for acknowledgement before the prompt is sent.
164-
* This preserves conversation history across edit rounds; if the parent session is
165-
* rejected, the run is failed immediately rather than continuing without prior context.
203+
* - Sends the runtime's conversation-reload RPC command before the prompt when resuming a
204+
* prior conversation, waiting for acknowledgement before the prompt is sent. `pi` uses
205+
* `new_session` with `parentSession`; Oh My Pi uses `switch_session` (see
206+
* {@link PiRpcResumeCommand}). This preserves conversation history across edit rounds;
207+
* if the parent session is rejected, the run is failed immediately rather than
208+
* continuing without prior context.
166209
* - Encodes and forwards `imagePaths` as base64 in the `prompt` RPC command, subject to
167210
* `MAX_IMAGE_COUNT` and `MAX_TOTAL_IMAGE_BYTES` budgets. Symlinks are resolved via
168211
* `realpathSync` and re-verified against `uploadRoot` to prevent path-escape attacks.
@@ -187,6 +230,8 @@ export function attachPiRpcSession({
187230
imagePaths,
188231
uploadRoot,
189232
parentSession,
233+
resumeCommand = 'new-session-parent',
234+
sessionDirName = DEFAULT_PI_SESSION_DIR_NAME,
190235
}: PiRpcSessionOptions): PiRpcSession {
191236
const stdin = child.stdin;
192237
const stdout = child.stdout;
@@ -198,7 +243,7 @@ export function attachPiRpcSession({
198243
}
199244

200245
const runStartedAt = Date.now();
201-
const sessionFilesBeforePrompt = snapshotPiSessionFiles(cwd);
246+
const sessionFilesBeforePrompt = snapshotPiSessionFiles(cwd, sessionDirName);
202247
let finished = false;
203248
let fatal = false;
204249
const sentFirstToken = { value: false };
@@ -310,14 +355,30 @@ export function attachPiRpcSession({
310355
});
311356
};
312357

313-
// If a prior session file path is provided, send new_session with
314-
// parentSession so pi loads the prior conversation history into the
315-
// new session, enabling conversational continuity across edit rounds.
316-
// Do not send the prompt until pi acknowledges this RPC: resumed prompts
317-
// intentionally contain only the latest user turn, so continuing after a
318-
// failed parent load would silently drop prior conversation context.
358+
// If a prior session file path is provided, ask the runtime to reload that
359+
// conversation before prompting, enabling continuity across edit rounds.
360+
// Do not send the prompt until the runtime acknowledges this RPC: resumed
361+
// prompts intentionally contain only the latest user turn, so continuing
362+
// after a failed load would silently drop prior conversation context.
319363
if (parentSession) {
320-
parentSessionRpcId = sendCommand(stdin, 'new_session', { parentSession });
364+
if (resumeCommand === 'switch-session') {
365+
// `switch_session` reports success even for a path that no longer
366+
// exists — it just opens an empty transcript. Since the daemon already
367+
// trimmed the prompt to the latest turn, that would silently erase the
368+
// conversation, so prove the file is there before handing it over.
369+
if (!fs.existsSync(parentSession)) {
370+
fail(
371+
`parent session file is missing: ${parentSession}`,
372+
'PI_PARENT_SESSION_FAILED',
373+
);
374+
} else {
375+
parentSessionRpcId = sendCommand(stdin, 'switch_session', {
376+
sessionPath: parentSession,
377+
});
378+
}
379+
} else {
380+
parentSessionRpcId = sendCommand(stdin, 'new_session', { parentSession });
381+
}
321382
} else {
322383
sendPromptCommand();
323384
}
@@ -348,6 +409,17 @@ export function attachPiRpcSession({
348409
);
349410
return;
350411
}
412+
// A `switch_session` that reports `cancelled` (an extension vetoed the
413+
// reload) left the runtime on a different transcript than the one this
414+
// turn was trimmed against. Treat it as a resume failure rather than
415+
// prompting into the wrong conversation.
416+
if (
417+
resumeCommand === 'switch-session' &&
418+
getRecord(raw.data)?.cancelled === true
419+
) {
420+
fail('parent session switch was cancelled', 'PI_PARENT_SESSION_FAILED');
421+
return;
422+
}
351423
sendPromptCommand();
352424
return;
353425
}
@@ -365,7 +437,11 @@ export function attachPiRpcSession({
365437
// Capture only the session file changed by this run. If another pi
366438
// process wrote to the shared session directory concurrently, the
367439
// resolver returns null instead of risking cross-conversation resume.
368-
capturedSessionPath = resolveSessionPathChangedSince(cwd, sessionFilesBeforePrompt);
440+
capturedSessionPath = resolveSessionPathChangedSince(
441+
cwd,
442+
sessionFilesBeforePrompt,
443+
sessionDirName,
444+
);
369445
// pi's RPC process stays alive after agent_end (designed for
370446
// multi-prompt sessions). The daemon's /api/chat is single-shot,
371447
// so close stdin and let the process exit naturally, or kill it

apps/daemon/src/app-config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,7 @@ const AGENT_CLI_ENV_KEYS: ReadonlyMap<string, ReadonlySet<string>> = new Map([
217217
['kilo', new Set(['KILO_BIN'])],
218218
['opencode', new Set(['OPENCODE_BIN'])],
219219
['pi', new Set(['PI_BIN'])],
220+
['omp', new Set(['OMP_BIN'])],
220221
['qoder', new Set(['QODER_BIN'])],
221222
['qwen', new Set(['QWEN_BIN'])],
222223
['trae-cli', new Set(['TRAE_CLI_BIN'])],

apps/daemon/src/mcp-agent-install.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export const AGENT_SLUGS = [
4040
'openclaw',
4141
'antigravity',
4242
'pi',
43+
'omp',
4344
'vibe',
4445
'hermes',
4546
'cline',
@@ -347,6 +348,18 @@ export function planAgentInstall(
347348
'not authoritatively documented. Paste this into pi’s MCP ' +
348349
'config (check `pi --help` for the exact location).',
349350
};
351+
case 'omp':
352+
return {
353+
kind: 'manual',
354+
slug,
355+
format: 'json',
356+
configPath: path.join(home, '.omp', 'agent', 'mcp.json'),
357+
snippet: genericMcpServersSnippet(spec, serverName),
358+
reason:
359+
'Oh My Pi exposes MCP, but its config path/schema is not ' +
360+
'authoritatively documented. Paste this into omp’s MCP config ' +
361+
'(check `omp --help` for the exact location).',
362+
};
350363
case 'hermes':
351364
return {
352365
kind: 'manual',

apps/daemon/src/native-session-recovery.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,18 @@ function sha256(value: string): string {
1818
return createHash('sha256').update(value, 'utf8').digest('hex');
1919
}
2020

21+
/**
22+
* Runtimes whose resume handle is a path to a session transcript on disk rather
23+
* than an opaque id — the `pi-rpc` family (pi and Oh My Pi). Their handle is
24+
* discovered by scanning the session directory after a turn, not reported by
25+
* the CLI, which is what the `session-file-*` metadata values describe.
26+
*/
27+
const SESSION_FILE_PATH_AGENT_IDS = new Set(['pi', 'omp']);
28+
2129
function handleKindForAgent(agentId: string | null): NativeSessionHandleKind {
2230
if (agentId === 'codex') return 'cli-thread-id';
2331
if (agentId === 'amr') return 'acp-session-handle';
24-
if (agentId === 'pi') return 'session-file-path';
32+
if (agentId && SESSION_FILE_PATH_AGENT_IDS.has(agentId)) return 'session-file-path';
2533
if (agentId) return 'opaque-id';
2634
return 'unknown';
2735
}
@@ -31,7 +39,7 @@ function handleKindForRuntime(
3139
): NativeSessionHandleKind {
3240
if (def.resumesSessionViaAcpLoad === true) return 'acp-session-handle';
3341
if (def.resumesSessionViaProfileStdio === true) return 'profile-session-id';
34-
if (def.id === 'pi') return 'session-file-path';
42+
if (SESSION_FILE_PATH_AGENT_IDS.has(def.id)) return 'session-file-path';
3543
if (def.capturesSessionIdFromStream === true) return 'cli-thread-id';
3644
return handleKindForAgent(def.id);
3745
}
@@ -60,7 +68,7 @@ function acquisitionForRuntime(
6068
if (!supported) return 'none';
6169
if (def.resumesSessionViaAcpLoad === true) return 'acp-session-load';
6270
if (def.resumesSessionViaProfileStdio === true) return 'profile-session-frame';
63-
if (def.id === 'pi') return 'session-file-discovered';
71+
if (SESSION_FILE_PATH_AGENT_IDS.has(def.id)) return 'session-file-discovered';
6472
if (def.capturesSessionIdFromStream === true) return 'stream-captured';
6573
if (def.resumesSessionViaCli === true) return 'daemon-specified';
6674
return 'unknown';
@@ -73,7 +81,7 @@ function continuationForRuntime(
7381
if (!supported) return 'none';
7482
if (def.resumesSessionViaAcpLoad === true) return 'acp-session-load';
7583
if (def.resumesSessionViaProfileStdio === true) return 'profile-stdio-resume';
76-
if (def.id === 'pi') return 'session-file-resume';
84+
if (SESSION_FILE_PATH_AGENT_IDS.has(def.id)) return 'session-file-resume';
7785
if (runtimeResumesSessionById(def)) return 'native-resume-by-id';
7886
return 'unknown';
7987
}

0 commit comments

Comments
 (0)