diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml
index 87ccc33..b853840 100644
--- a/.github/ISSUE_TEMPLATE/bug-report.yml
+++ b/.github/ISSUE_TEMPLATE/bug-report.yml
@@ -54,8 +54,8 @@ body:
id: opencode-version
attributes:
label: OpenCode version
- description: The tested baseline is beta 18050. Do not include a server address.
- placeholder: "0.0.0-beta-18050"
+ description: The tested baseline is beta 18387. Do not include a server address.
+ placeholder: "0.0.0-beta-18387"
validations:
required: true
diff --git a/TODO.md b/TODO.md
index 35a6c0f..e7e2c7c 100644
--- a/TODO.md
+++ b/TODO.md
@@ -64,7 +64,7 @@ Android, and CI checks an empty native application.
## 2. React Native client compatibility spike
- [x] Check the V2 client docs and replace the pinned `@next` build with tested
- beta 18050.
+ beta 18387.
- [x] Record the exact tested OpenCode application versions and behavioral
compatibility probes; report differing versions without rejecting a server
that passes those probes.
@@ -398,8 +398,8 @@ against OpenCode.
- [ ] Add native hardware-keyboard commands without changing multiline Enter
behavior.
-- [ ] Add command and skill completion.
-- [ ] Add file, agent, and skill mentions.
+- [x] Add command completion.
+- [x] Add file, agent, and skill mentions.
- [ ] Add phone-file attachments using bounded inline data URLs and server-file
attachments using server-accessible file URLs.
- [ ] Implement filesystem find, list, and read through generated operations.
diff --git a/apps/mobile/src/screens/prompt-admission-model.test.ts b/apps/mobile/src/screens/prompt-admission-model.test.ts
index 8206489..d5ec2b8 100644
--- a/apps/mobile/src/screens/prompt-admission-model.test.ts
+++ b/apps/mobile/src/screens/prompt-admission-model.test.ts
@@ -22,6 +22,7 @@ test("creates a stable caller-owned message ID before transmission", () => {
delivery: "queue",
durable: false,
id: "msg_12345678123442348234123456789abc",
+ kind: "prompt",
status: "submitting",
submittedAtMs: 42,
});
diff --git a/apps/mobile/src/screens/prompt-admission-model.ts b/apps/mobile/src/screens/prompt-admission-model.ts
index d4ce305..576648c 100644
--- a/apps/mobile/src/screens/prompt-admission-model.ts
+++ b/apps/mobile/src/screens/prompt-admission-model.ts
@@ -1,6 +1,7 @@
import * as Crypto from "expo-crypto";
export type PromptDelivery = "queue" | "steer";
+export type PromptAdmissionKind = "command" | "prompt";
export type PromptAdmissionStatus =
| "submitting"
@@ -19,6 +20,7 @@ export type PromptAdmission = {
draftRevision?: number;
durable: boolean;
id: string;
+ kind: PromptAdmissionKind;
retryOffered?: boolean;
serverAdmittedAtMs?: number;
status: PromptAdmissionStatus;
@@ -43,6 +45,7 @@ export function createPromptAdmission(
...(delivery ? { delivery } : {}),
durable: false,
id: `msg_${randomUUID().replaceAll("-", "")}`,
+ kind: "prompt",
status: "submitting",
submittedAtMs: now(),
};
diff --git a/apps/mobile/src/screens/session-composer-model.test.ts b/apps/mobile/src/screens/session-composer-model.test.ts
new file mode 100644
index 0000000..81c7b78
--- /dev/null
+++ b/apps/mobile/src/screens/session-composer-model.test.ts
@@ -0,0 +1,277 @@
+import { expect, test } from "@jest/globals";
+import type {
+ AgentInfo,
+ CommandInfo,
+ FileSystemEntry,
+ SkillInfo,
+} from "@opencode2-mobile/opencode-adapter";
+
+import {
+ applyMentionCompletion,
+ applySlashCompletion,
+ findMentionTrigger,
+ listMentionCompletions,
+ listSlashCompletions,
+ rebaseComposerMentions,
+ resolveComposerSubmitIntent,
+ serverFileUri,
+} from "./session-composer-model";
+
+const commands = [{ name: "review" }, { name: "release-notes" }] as CommandInfo[];
+const agents = [
+ { hidden: false, id: "build", mode: "primary", name: "Build" },
+ { hidden: false, id: "explore", mode: "subagent", name: "Explore" },
+] as AgentInfo[];
+const skills = [
+ {
+ content: "Content",
+ id: "release",
+ location: "/skills/release.md",
+ name: "Release workflow",
+ slash: false,
+ },
+] as SkillInfo[];
+const files = [
+ { path: "src/index.ts", type: "file" },
+ { path: "src", type: "directory" },
+] as FileSystemEntry[];
+
+test("keeps slash completion command-only", () => {
+ expect(listSlashCompletions("/re", commands).map((item) => item.name)).toEqual([
+ "release-notes",
+ "review",
+ ]);
+ expect(listSlashCompletions("Explain /review", commands)).toEqual([]);
+ expect(listSlashCompletions("/review arguments", commands)).toEqual([]);
+ expect(applySlashCompletion("/rev", { label: "review", name: "review" })).toBe("/review ");
+});
+
+test("finds an at trigger at the caret only after a text boundary", () => {
+ expect(findMentionTrigger("Ask @src/in", { end: 11, start: 11 })).toEqual({
+ end: 11,
+ query: "src/in",
+ start: 4,
+ });
+ expect(findMentionTrigger("mail@example", { end: 12, start: 12 })).toBeUndefined();
+ expect(findMentionTrigger("@one two", { end: 8, start: 8 })).toBeUndefined();
+ expect(findMentionTrigger("@file", { end: 3, start: 1 })).toBeUndefined();
+ expect(findMentionTrigger("Ask @src/index.ts now", { end: 8, start: 8 })).toEqual({
+ end: 17,
+ query: "src",
+ start: 4,
+ });
+});
+
+test("combines files, skills, and non-primary agents for at completion", () => {
+ expect(listMentionCompletions("", agents, skills, files).map((item) => item.type)).toEqual([
+ "agent",
+ "skill",
+ "file",
+ ]);
+ expect(listMentionCompletions("rel", agents, skills, [])).toMatchObject([
+ { id: "release", type: "skill" },
+ ]);
+ expect(listMentionCompletions("index", agents, skills, files)).toMatchObject([
+ { path: "src/index.ts", type: "file" },
+ ]);
+});
+
+test("preserves fuzzy-ranked file results from the server", () => {
+ const rankedFiles = [
+ { path: "src/session-input.ts", type: "file" },
+ { path: "src/index.ts", type: "file" },
+ ] as FileSystemEntry[];
+
+ expect(listMentionCompletions("sit", [], [], rankedFiles).map((item) => item.label)).toEqual([
+ "src/session-input.ts",
+ "src/index.ts",
+ ]);
+});
+
+test("inserts a structured mention and rebases it around later edits", () => {
+ const trigger = findMentionTrigger("Ask @ind now", { end: 8, start: 8 });
+ if (!trigger) throw new Error("Expected mention trigger");
+ const inserted = applyMentionCompletion(
+ "Ask @ind now",
+ trigger,
+ { label: "src/index.ts", path: "src/index.ts", type: "file" },
+ [],
+ );
+
+ expect(inserted.draft).toBe("Ask @src/index.ts now");
+ expect(inserted.mentions).toEqual([
+ {
+ mention: { end: 17, start: 4, text: "@src/index.ts" },
+ path: "src/index.ts",
+ type: "file",
+ },
+ ]);
+ expect(
+ rebaseComposerMentions(inserted.draft, `Please ${inserted.draft}`, inserted.mentions),
+ ).toEqual([
+ {
+ mention: { end: 24, start: 11, text: "@src/index.ts" },
+ path: "src/index.ts",
+ type: "file",
+ },
+ ]);
+ expect(rebaseComposerMentions(inserted.draft, "Ask @broken now", inserted.mentions)).toEqual([]);
+});
+
+test("keeps separate ranges when the same item is mentioned twice", () => {
+ const firstTrigger = findMentionTrigger("@src", { end: 4, start: 4 });
+ if (!firstTrigger) throw new Error("Expected first mention trigger");
+ const first = applyMentionCompletion(
+ "@src",
+ firstTrigger,
+ { label: "src/index.ts", path: "src/index.ts", type: "file" },
+ [],
+ );
+ const secondDraft = `${first.draft}@src`;
+ const secondTrigger = findMentionTrigger(secondDraft, {
+ end: secondDraft.length,
+ start: secondDraft.length,
+ });
+ if (!secondTrigger) throw new Error("Expected second mention trigger");
+ const second = applyMentionCompletion(
+ secondDraft,
+ secondTrigger,
+ { label: "src/index.ts", path: "src/index.ts", type: "file" },
+ first.mentions,
+ );
+
+ expect(second.mentions).toHaveLength(2);
+ expect(second.mentions.map((item) => item.mention.start)).toEqual([0, 14]);
+});
+
+test("keeps the surviving attachment identity when equal mention text repeats", () => {
+ const mentions = [
+ {
+ mention: { end: 4, start: 0, text: "@foo" },
+ name: "foo",
+ type: "agent" as const,
+ },
+ {
+ id: "foo",
+ mention: { end: 9, start: 5, text: "@foo" },
+ type: "skill" as const,
+ },
+ ];
+
+ expect(rebaseComposerMentions("@foo @foo", "@foo", mentions, { end: 5, start: 0 })).toEqual([
+ {
+ id: "foo",
+ mention: { end: 4, start: 0, text: "@foo" },
+ type: "skill",
+ },
+ ]);
+ expect(rebaseComposerMentions("@foo @foo", "@foo", mentions, { end: 9, start: 4 })).toEqual([
+ {
+ mention: { end: 4, start: 0, text: "@foo" },
+ name: "foo",
+ type: "agent",
+ },
+ ]);
+});
+
+test("builds generated prompt attachments with UTF-16 mention ranges", () => {
+ const draft = "Check æ @src/a#b.ts with @Explore and @release";
+ const fileText = "@src/a#b.ts";
+ const agentText = "@Explore";
+ const skillText = "@release";
+ const fileStart = draft.indexOf(fileText);
+ const agentStart = draft.indexOf(agentText);
+ const skillStart = draft.indexOf(skillText);
+
+ expect(
+ resolveComposerSubmitIntent(
+ draft,
+ commands,
+ [
+ {
+ mention: { end: fileStart + fileText.length, start: fileStart, text: fileText },
+ path: "src/a#b.ts",
+ type: "file",
+ },
+ {
+ mention: { end: agentStart + agentText.length, start: agentStart, text: agentText },
+ name: "Explore",
+ type: "agent",
+ },
+ {
+ id: "release",
+ mention: { end: skillStart + skillText.length, start: skillStart, text: skillText },
+ type: "skill",
+ },
+ ],
+ { directory: "/workspace" },
+ ),
+ ).toEqual({
+ agents: [{ mention: { end: 33, start: 25, text: "@Explore" }, name: "Explore" }],
+ files: [
+ {
+ mention: { end: 19, start: 8, text: "@src/a#b.ts" },
+ name: "src/a#b.ts",
+ uri: "file:///workspace/src/a%23b.ts",
+ },
+ ],
+ skills: [{ id: "release", mention: { end: 46, start: 38, text: "@release" } }],
+ type: "prompt",
+ });
+});
+
+test("preserves command arguments and structured mentions", () => {
+ const draft = "/review @src/index.ts\nthen tests";
+ expect(
+ resolveComposerSubmitIntent(
+ draft,
+ commands,
+ [
+ {
+ mention: { end: 21, start: 8, text: "@src/index.ts" },
+ path: "src/index.ts",
+ type: "file",
+ },
+ ],
+ { directory: "/workspace" },
+ ),
+ ).toMatchObject({
+ arguments: "@src/index.ts\nthen tests",
+ command: "review",
+ files: [
+ {
+ mention: { end: 13, start: 0, text: "@src/index.ts" },
+ uri: "file:///workspace/src/index.ts",
+ },
+ ],
+ type: "command",
+ });
+ expect(
+ resolveComposerSubmitIntent("/unknown value", commands, [], { directory: "/workspace" }),
+ ).toEqual({
+ type: "prompt",
+ });
+});
+
+test("preserves multiline command arguments after removing one separator space", () => {
+ expect(
+ resolveComposerSubmitIntent("/review indented", commands, [], { directory: "/workspace" }),
+ ).toMatchObject({ arguments: " indented", type: "command" });
+ expect(
+ resolveComposerSubmitIntent("/review\nnext line", commands, [], { directory: "/workspace" }),
+ ).toMatchObject({ arguments: "\nnext line", type: "command" });
+});
+
+test("creates server-local file URLs without treating hostile path characters as URL syntax", () => {
+ expect(serverFileUri("/workspace", "odd name?#.ts")).toBe(
+ "file:///workspace/odd%20name%3F%23.ts",
+ );
+ expect(serverFileUri("C:\\workspace", "src\\index.ts")).toBe("file:///C:/workspace/src/index.ts");
+ expect(() => serverFileUri("/workspace", "../secret.txt")).toThrow(
+ "FILE_MENTION_OUTSIDE_LOCATION",
+ );
+ expect(() => serverFileUri("/workspace", "/etc/passwd")).toThrow("FILE_MENTION_OUTSIDE_LOCATION");
+ expect(() => serverFileUri("C:\\workspace", "C:\\outside.txt")).toThrow(
+ "FILE_MENTION_OUTSIDE_LOCATION",
+ );
+});
diff --git a/apps/mobile/src/screens/session-composer-model.ts b/apps/mobile/src/screens/session-composer-model.ts
new file mode 100644
index 0000000..276646f
--- /dev/null
+++ b/apps/mobile/src/screens/session-composer-model.ts
@@ -0,0 +1,314 @@
+import type {
+ AgentInfo,
+ CommandInfo,
+ FileSystemEntry,
+ LocationRef,
+ SkillInfo,
+} from "@opencode2-mobile/opencode-adapter";
+import type { SessionDraftMention } from "../storage/draft-repository";
+
+type MentionRange = { end: number; start: number; text: string };
+
+export type ComposerMention = SessionDraftMention;
+
+export type MentionCompletion =
+ | { description?: string; label: string; path: string; type: "file" }
+ | { description?: string; label: string; name: string; type: "agent" }
+ | { description?: string; id: string; label: string; type: "skill" };
+
+export type SlashCompletion = {
+ description?: string;
+ label: string;
+ name: string;
+};
+
+type ComposerAttachments = {
+ agents?: Array<{ mention: MentionRange; name: string }>;
+ files?: Array<{ mention: MentionRange; name: string; uri: string }>;
+ skills?: Array<{ id: string; mention: MentionRange }>;
+};
+
+export type ComposerSubmitIntent =
+ | ({ type: "prompt" } & ComposerAttachments)
+ | ({ arguments?: string; command: string; type: "command" } & ComposerAttachments);
+
+export type MentionTrigger = { end: number; query: string; start: number };
+
+const maximumSlashCompletions = 4;
+const maximumMentionCompletions = 10;
+
+export function listSlashCompletions(draft: string, commands: readonly CommandInfo[]) {
+ const match = draft.match(/^\/([^\s/]*)$/);
+ if (!match) return [];
+ const query = (match[1] ?? "").toLocaleLowerCase();
+
+ return commands
+ .filter(
+ (command) =>
+ command.name.toLocaleLowerCase().includes(query) ||
+ command.description?.toLocaleLowerCase().includes(query),
+ )
+ .sort((first, second) => {
+ const firstStarts = first.name.toLocaleLowerCase().startsWith(query);
+ const secondStarts = second.name.toLocaleLowerCase().startsWith(query);
+ if (firstStarts !== secondStarts) return firstStarts ? -1 : 1;
+ return first.name.localeCompare(second.name);
+ })
+ .slice(0, maximumSlashCompletions)
+ .map(
+ (command): SlashCompletion => ({
+ ...(command.description ? { description: command.description } : {}),
+ label: command.name,
+ name: command.name,
+ }),
+ );
+}
+
+export function findMentionTrigger(draft: string, selection: { end: number; start: number }) {
+ if (selection.start !== selection.end) return undefined;
+ const beforeCursor = draft.slice(0, selection.start);
+ const start = beforeCursor.lastIndexOf("@");
+ if (start < 0) return undefined;
+ const beforeTrigger = start === 0 ? undefined : beforeCursor[start - 1];
+ const query = beforeCursor.slice(start + 1);
+ if ((beforeTrigger !== undefined && !/\s/.test(beforeTrigger)) || /\s/.test(query)) {
+ return undefined;
+ }
+ const suffix = draft.slice(selection.end);
+ const nextWhitespace = suffix.search(/\s/);
+ const end = nextWhitespace < 0 ? draft.length : selection.end + nextWhitespace;
+ return { end, query, start } satisfies MentionTrigger;
+}
+
+export function listMentionCompletions(
+ query: string,
+ agents: readonly AgentInfo[],
+ skills: readonly SkillInfo[],
+ files: readonly FileSystemEntry[],
+) {
+ const normalizedQuery = query.toLocaleLowerCase();
+ const nonFileOptions: MentionCompletion[] = [
+ ...agents
+ .filter((agent) => !agent.hidden && agent.mode !== "primary")
+ .map((agent) => ({
+ ...(agent.description ? { description: agent.description } : {}),
+ label: agent.name,
+ name: agent.name,
+ type: "agent" as const,
+ })),
+ ...skills.map((skill) => ({
+ ...(skill.description ? { description: skill.description } : {}),
+ id: skill.id,
+ label: skill.name,
+ type: "skill" as const,
+ })),
+ ];
+ const fileOptions: MentionCompletion[] = files
+ .filter((file) => file.type === "file")
+ .map((file) => ({ label: file.path, path: file.path, type: "file" as const }));
+
+ const matchingNonFiles = nonFileOptions
+ .filter((option) => mentionSearchText(option).includes(normalizedQuery))
+ .sort((first, second) => {
+ const firstStarts = mentionValue(first).toLocaleLowerCase().startsWith(normalizedQuery);
+ const secondStarts = mentionValue(second).toLocaleLowerCase().startsWith(normalizedQuery);
+ if (firstStarts !== secondStarts) return firstStarts ? -1 : 1;
+ const kindOrder = mentionKindOrder(first.type) - mentionKindOrder(second.type);
+ return kindOrder || mentionValue(first).localeCompare(mentionValue(second));
+ });
+ return [...matchingNonFiles, ...fileOptions].slice(0, maximumMentionCompletions);
+}
+
+export function applySlashCompletion(draft: string, completion: SlashCompletion) {
+ if (!/^\/[^\s/]*$/.test(draft)) return draft;
+ return `/${completion.name} `;
+}
+
+export function applyMentionCompletion(
+ draft: string,
+ trigger: MentionTrigger,
+ completion: MentionCompletion,
+ mentions: readonly ComposerMention[],
+) {
+ const value = mentionValue(completion);
+ const text = `@${value}`;
+ const suffix = draft.slice(trigger.end);
+ const trailing = suffix.startsWith(" ") ? "" : " ";
+ const replacement = `${text}${trailing}`;
+ const nextDraft = `${draft.slice(0, trigger.start)}${replacement}${suffix}`;
+ const delta = replacement.length - (trigger.end - trigger.start);
+ const shifted = mentions
+ .filter(
+ (mention) => mention.mention.end <= trigger.start || mention.mention.start >= trigger.end,
+ )
+ .map((mention) =>
+ mention.mention.start >= trigger.end ? shiftMention(mention, delta) : mention,
+ );
+ const range = { end: trigger.start + text.length, start: trigger.start, text };
+ const nextMention: ComposerMention =
+ completion.type === "file"
+ ? { mention: range, path: completion.path, type: "file" }
+ : completion.type === "agent"
+ ? { mention: range, name: completion.name, type: "agent" }
+ : { id: completion.id, mention: range, type: "skill" };
+
+ return {
+ draft: nextDraft,
+ mentions: [...shifted, nextMention].sort(
+ (first, second) => first.mention.start - second.mention.start,
+ ),
+ selection: { end: range.end + trailing.length, start: range.end + trailing.length },
+ };
+}
+
+export function rebaseComposerMentions(
+ previousDraft: string,
+ nextDraft: string,
+ mentions: readonly ComposerMention[],
+ selection?: { end: number; start: number },
+) {
+ if (previousDraft === nextDraft || mentions.length === 0) return [...mentions];
+ let start = 0;
+ while (start < previousDraft.length && previousDraft[start] === nextDraft[start]) start += 1;
+ let previousEnd = previousDraft.length;
+ let nextEnd = nextDraft.length;
+ while (
+ previousEnd > start &&
+ nextEnd > start &&
+ previousDraft[previousEnd - 1] === nextDraft[nextEnd - 1]
+ ) {
+ previousEnd -= 1;
+ nextEnd -= 1;
+ }
+ if (selection && selection.start < selection.end) {
+ const insertedLength =
+ nextDraft.length - (previousDraft.length - selection.end + selection.start);
+ if (
+ insertedLength >= 0 &&
+ `${previousDraft.slice(0, selection.start)}${nextDraft.slice(
+ selection.start,
+ selection.start + insertedLength,
+ )}${previousDraft.slice(selection.end)}` === nextDraft
+ ) {
+ start = selection.start;
+ previousEnd = selection.end;
+ nextEnd = selection.start + insertedLength;
+ }
+ }
+ const delta = nextEnd - previousEnd;
+ return mentions.flatMap((mention) => {
+ if (previousEnd <= mention.mention.start) return [shiftMention(mention, delta)];
+ if (start >= mention.mention.end) return [mention];
+ return [];
+ });
+}
+
+export function resolveComposerSubmitIntent(
+ draft: string,
+ commands: readonly CommandInfo[],
+ mentions: readonly ComposerMention[],
+ location: LocationRef,
+): ComposerSubmitIntent {
+ const match = draft.match(/^\/([^\s/]+)([\s\S]*)$/);
+ if (!match) return { ...composerAttachments(draft, mentions, location), type: "prompt" };
+ const name = match[1] ?? "";
+ const remainder = match[2] ?? "";
+ const command = commands.find((candidate) => candidate.name === name);
+ if (!command) return { ...composerAttachments(draft, mentions, location), type: "prompt" };
+ const separatorLength = remainder.startsWith(" ") ? 1 : 0;
+ const argumentsText = remainder.slice(separatorLength);
+ const argumentsStart = 1 + name.length + separatorLength;
+ const attachments = composerAttachments(draft, mentions, location, argumentsStart);
+ return {
+ ...attachments,
+ ...(argumentsText ? { arguments: argumentsText } : {}),
+ command: command.name,
+ type: "command",
+ };
+}
+
+export function serverFileUri(directory: string, path: string) {
+ const normalizedDirectory = directory.replaceAll("\\", "/").replace(/\/+$/, "");
+ const normalizedPath = path.replaceAll("\\", "/");
+ if (!isSafeRelativeFilePath(normalizedPath)) throw new Error("FILE_MENTION_OUTSIDE_LOCATION");
+ const absolute = `${normalizedDirectory}/${normalizedPath}`;
+ const url = new URL("file:///");
+ url.pathname = absolute.startsWith("/") ? absolute : `/${absolute}`;
+ return url.href;
+}
+
+function isSafeRelativeFilePath(path: string) {
+ return (
+ Boolean(path) &&
+ !path.startsWith("/") &&
+ !/^[A-Za-z]:\//.test(path) &&
+ path.split("/").every((segment) => Boolean(segment) && segment !== "." && segment !== "..")
+ );
+}
+
+function composerAttachments(
+ draft: string,
+ mentions: readonly ComposerMention[],
+ location: LocationRef,
+ offset = 0,
+) {
+ const valid = mentions.filter(
+ (mention) =>
+ mention.mention.start >= offset &&
+ mention.mention.end <= draft.length &&
+ draft.slice(mention.mention.start, mention.mention.end) === mention.mention.text,
+ );
+ const files = valid.flatMap((mention) =>
+ mention.type === "file"
+ ? [
+ {
+ mention: offsetMention(mention.mention, offset),
+ name: mention.path,
+ uri: serverFileUri(location.directory, mention.path),
+ },
+ ]
+ : [],
+ );
+ const agents = valid.flatMap((mention) =>
+ mention.type === "agent"
+ ? [{ mention: offsetMention(mention.mention, offset), name: mention.name }]
+ : [],
+ );
+ const skills = valid.flatMap((mention) =>
+ mention.type === "skill"
+ ? [{ id: mention.id, mention: offsetMention(mention.mention, offset) }]
+ : [],
+ );
+ return {
+ ...(agents.length ? { agents } : {}),
+ ...(files.length ? { files } : {}),
+ ...(skills.length ? { skills } : {}),
+ };
+}
+
+function offsetMention(mention: MentionRange, offset: number) {
+ return { ...mention, end: mention.end - offset, start: mention.start - offset };
+}
+
+function mentionSearchText(option: MentionCompletion) {
+ return `${mentionValue(option)}\n${option.label}\n${option.description ?? ""}`.toLocaleLowerCase();
+}
+
+function mentionValue(option: MentionCompletion) {
+ return option.type === "file" ? option.path : option.type === "agent" ? option.name : option.id;
+}
+
+function mentionKindOrder(type: MentionCompletion["type"]) {
+ return type === "agent" ? 0 : type === "skill" ? 1 : 2;
+}
+
+function shiftMention(mention: ComposerMention, delta: number): ComposerMention {
+ return {
+ ...mention,
+ mention: {
+ ...mention.mention,
+ end: mention.mention.end + delta,
+ start: mention.mention.start + delta,
+ },
+ };
+}
diff --git a/apps/mobile/src/screens/session-composer.test.tsx b/apps/mobile/src/screens/session-composer.test.tsx
index 5e6d351..934d6d3 100644
--- a/apps/mobile/src/screens/session-composer.test.tsx
+++ b/apps/mobile/src/screens/session-composer.test.tsx
@@ -1,13 +1,24 @@
import { expect, jest, test } from "@jest/globals";
-import type { AgentInfo, ModelInfo, ModelRef } from "@opencode2-mobile/opencode-adapter";
+import type {
+ AgentInfo,
+ CommandInfo,
+ FileSystemEntry,
+ ModelInfo,
+ ModelRef,
+ SkillInfo,
+} from "@opencode2-mobile/opencode-adapter";
import { fireEvent, render, screen, within } from "@testing-library/react-native";
import { useState } from "react";
import { Keyboard } from "react-native";
import type { PromptDelivery } from "./prompt-admission-model";
import { SessionComposer } from "./session-composer";
+import type { ComposerMention, ComposerSubmitIntent } from "./session-composer-model";
-const agents = [{ hidden: false, id: "build", mode: "primary", name: "Build" }] as AgentInfo[];
+const agents = [
+ { hidden: false, id: "build", mode: "primary", name: "Build" },
+ { hidden: false, id: "explore", mode: "subagent", name: "Explore" },
+] as AgentInfo[];
const models = [
{
enabled: true,
@@ -18,6 +29,24 @@ const models = [
variants: [{ id: "deep" }],
},
] as ModelInfo[];
+const commands = [{ description: "Review changes", name: "review" }] as CommandInfo[];
+const skills = [
+ {
+ content: "Release instructions",
+ id: "release",
+ location: "/workspace/.opencode/skills/release.md",
+ name: "Release workflow",
+ slash: true,
+ },
+ {
+ content: "Automatic context",
+ id: "automatic",
+ location: "/workspace/.opencode/skills/automatic.md",
+ name: "Automatic",
+ slash: false,
+ },
+] as SkillInfo[];
+const files = [{ path: "src/index.ts", type: "file" }] as FileSystemEntry[];
test("keeps the native prompt multiline and submits through an explicit control", () => {
const onSubmit = jest.fn();
@@ -113,15 +142,22 @@ test("keeps the editor read-only until its encrypted draft has loaded", () => {
,
);
@@ -135,14 +171,21 @@ test("selects a server agent and model variant", () => {
,
);
@@ -163,26 +206,128 @@ test("selects a server agent and model variant", () => {
});
});
-function ComposerHarness({ active = false, onSubmit }: { active?: boolean; onSubmit: () => void }) {
+test("completes and submits a command with multiline Unicode arguments", () => {
+ const onSubmit = jest.fn<(intent: ComposerSubmitIntent) => void>();
+ render();
+
+ const input = screen.getByLabelText("Prompt");
+ fireEvent(input, "focus");
+ fireEvent.changeText(input, "/rev");
+ expect(screen.getByLabelText("Command suggestions")).toBeOnTheScreen();
+ fireEvent.press(screen.getByRole("button", { name: "/review, command" }));
+ expect(screen.getByLabelText("Prompt").props.value).toBe("/review ");
+ fireEvent.changeText(screen.getByLabelText("Prompt"), "/review src/æ.ts\nfocus errors");
+ fireEvent.press(screen.getByRole("button", { name: "Send" }));
+
+ expect(onSubmit).toHaveBeenCalledWith({
+ arguments: "src/æ.ts\nfocus errors",
+ command: "review",
+ type: "command",
+ });
+});
+
+test("keeps slash search command-only", () => {
+ render();
+
+ const input = screen.getByLabelText("Prompt");
+ fireEvent(input, "focus");
+ fireEvent.changeText(input, "/");
+
+ expect(screen.getByRole("button", { name: "/review, command" })).toBeOnTheScreen();
+ expect(screen.queryByRole("button", { name: /release/ })).not.toBeOnTheScreen();
+});
+
+test("does not submit a slash command with arguments before the catalog loads", () => {
+ const onSubmit = jest.fn();
+ render();
+
+ const input = screen.getByLabelText("Prompt");
+ fireEvent.changeText(input, "/review src/index.ts");
+ fireEvent.press(screen.getByRole("button", { name: "Send" }));
+
+ expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
+ expect(onSubmit).not.toHaveBeenCalled();
+});
+
+test("does not submit slash input when the command catalog is unavailable", () => {
+ const onSubmit = jest.fn();
+ render();
+
+ const input = screen.getByLabelText("Prompt");
+ fireEvent.changeText(input, "/review src/index.ts");
+ fireEvent.press(screen.getByRole("button", { name: "Send" }));
+
+ expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
+ expect(onSubmit).not.toHaveBeenCalled();
+});
+
+test("searches files, skills, and agents with at and submits a structured skill mention", () => {
+ const onSubmit = jest.fn<(intent: ComposerSubmitIntent) => void>();
+ render();
+
+ const input = screen.getByLabelText("Prompt");
+ fireEvent(input, "focus");
+ fireEvent.changeText(input, "Ask @");
+ fireEvent(input, "selectionChange", {
+ nativeEvent: { selection: { end: 5, start: 5 } },
+ });
+
+ expect(screen.getByLabelText("File, skill, and agent suggestions")).toBeOnTheScreen();
+ expect(screen.getByRole("button", { name: "@src/index.ts, file" })).toBeOnTheScreen();
+ expect(screen.getByRole("button", { name: "@Explore, agent" })).toBeOnTheScreen();
+ expect(screen.getByRole("button", { name: "@release, skill" })).toBeOnTheScreen();
+ fireEvent.press(screen.getByRole("button", { name: "@release, skill" }));
+ fireEvent.press(screen.getByRole("button", { name: "Send" }));
+
+ expect(onSubmit).toHaveBeenCalledWith({
+ skills: [{ id: "release", mention: { end: 12, start: 4, text: "@release" } }],
+ type: "prompt",
+ });
+});
+
+function ComposerHarness({
+ active = false,
+ completionLoading = false,
+ completionUnavailable = false,
+ onSubmit,
+}: {
+ active?: boolean;
+ completionLoading?: boolean;
+ completionUnavailable?: boolean;
+ onSubmit: (intent: ComposerSubmitIntent) => void;
+}) {
const [draft, setDraft] = useState("");
const [delivery, setDelivery] = useState();
const [agent, setAgent] = useState();
const [model, setModel] = useState();
+ const [mentions, setMentions] = useState([]);
return (
{
+ setDraft(content);
+ setMentions(nextMentions);
+ }}
onModelChange={setModel}
+ onMentionSearchChange={jest.fn()}
onSubmit={onSubmit}
+ skills={skills}
/>
);
}
diff --git a/apps/mobile/src/screens/session-composer.tsx b/apps/mobile/src/screens/session-composer.tsx
index 04cfeab..357ddb4 100644
--- a/apps/mobile/src/screens/session-composer.tsx
+++ b/apps/mobile/src/screens/session-composer.tsx
@@ -1,4 +1,12 @@
-import type { AgentInfo, ModelInfo, ModelRef } from "@opencode2-mobile/opencode-adapter";
+import type {
+ AgentInfo,
+ CommandInfo,
+ FileSystemEntry,
+ LocationRef,
+ ModelInfo,
+ ModelRef,
+ SkillInfo,
+} from "@opencode2-mobile/opencode-adapter";
import { useDeferredValue, useEffect, useRef, useState } from "react";
import {
FlatList,
@@ -15,6 +23,19 @@ import {
import { ModalSheet } from "../components/modal-sheet";
import { palette, radius, space, typeRamp } from "../theme";
import type { PromptDelivery } from "./prompt-admission-model";
+import {
+ applyMentionCompletion,
+ applySlashCompletion,
+ type ComposerMention,
+ type ComposerSubmitIntent,
+ findMentionTrigger,
+ listMentionCompletions,
+ listSlashCompletions,
+ type MentionCompletion,
+ rebaseComposerMentions,
+ resolveComposerSubmitIntent,
+ type SlashCompletion,
+} from "./session-composer-model";
const maximumDraftLength = 32_000;
@@ -22,6 +43,9 @@ export function SessionComposer({
active,
agent,
agents,
+ commands,
+ completionLoading,
+ completionUnavailable,
delivery,
disabled,
draft,
@@ -29,17 +53,28 @@ export function SessionComposer({
error,
focusOnMount,
largeText,
+ location,
+ mentionAgents,
+ mentionFiles,
+ mentionLoading,
+ mentions,
+ mentionUnavailable,
model,
models,
onAgentChange,
onDeliveryChange,
onDraftChange,
onModelChange,
+ onMentionSearchChange,
onSubmit,
+ skills,
}: {
active: boolean;
agent?: string | undefined;
agents: AgentInfo[];
+ commands: CommandInfo[];
+ completionLoading?: boolean | undefined;
+ completionUnavailable?: boolean | undefined;
delivery?: PromptDelivery | undefined;
disabled?: boolean | undefined;
draft: string;
@@ -47,13 +82,21 @@ export function SessionComposer({
error?: string | undefined;
focusOnMount?: boolean | undefined;
largeText: boolean;
+ location: LocationRef;
+ mentionAgents: AgentInfo[];
+ mentionFiles: FileSystemEntry[];
+ mentionLoading?: boolean | undefined;
+ mentions: ComposerMention[];
+ mentionUnavailable?: boolean | undefined;
model?: ModelRef | undefined;
models: ModelInfo[];
onAgentChange: (agent: string) => void;
onDeliveryChange: (delivery: PromptDelivery) => void;
- onDraftChange: (draft: string) => void;
+ onDraftChange: (draft: string, mentions: ComposerMention[]) => void;
onModelChange: (model: ModelRef) => void;
- onSubmit: () => void;
+ onMentionSearchChange: (query: string | undefined) => void;
+ onSubmit: (intent: ComposerSubmitIntent) => void;
+ skills: SkillInfo[];
}) {
const inputRef = useRef(null);
const [agentPickerOpen, setAgentPickerOpen] = useState(false);
@@ -61,6 +104,7 @@ export function SessionComposer({
const [focused, setFocused] = useState(false);
const [modelPickerOpen, setModelPickerOpen] = useState(false);
const [modelSearch, setModelSearch] = useState("");
+ const [selection, setSelection] = useState({ end: draft.length, start: draft.length });
const deferredModelSearch = useDeferredValue(modelSearch.trim().toLocaleLowerCase());
const deferredAgentSearch = useDeferredValue(agentSearch.trim().toLocaleLowerCase());
const selectedAgent = agents.find((candidate) => candidate.id === agent);
@@ -82,8 +126,23 @@ export function SessionComposer({
)
: agents;
const expanded = largeText || focused || agentPickerOpen || modelPickerOpen;
+ const completions = listSlashCompletions(draft, commands);
+ const mentionTrigger = findMentionTrigger(draft, selection);
+ const mentionCompletions = mentionTrigger
+ ? listMentionCompletions(mentionTrigger.query, mentionAgents, skills, mentionFiles)
+ : [];
+ const submitIntent = resolveComposerSubmitIntent(draft, commands, mentions, location);
+ const slashCatalogPending = completionLoading && draft.startsWith("/");
+ const slashCatalogUnavailable = completionUnavailable && draft.startsWith("/");
+ const submitHint = slashCatalogPending
+ ? "Wait for commands to load."
+ : slashCatalogUnavailable
+ ? "Commands are unavailable."
+ : undefined;
const canSubmit =
!disabled &&
+ !slashCatalogPending &&
+ !slashCatalogUnavailable &&
draft.trim().length > 0 &&
(!active || delivery === "queue" || delivery === "steer");
@@ -93,12 +152,52 @@ export function SessionComposer({
return () => cancelAnimationFrame(frame);
}, [editable, focusOnMount]);
+ useEffect(() => {
+ if (!focused) {
+ setSelection({ end: draft.length, start: draft.length });
+ return;
+ }
+ setSelection((current) =>
+ current.end <= draft.length ? current : { end: draft.length, start: draft.length },
+ );
+ }, [draft, focused]);
+
+ useEffect(() => {
+ onMentionSearchChange(mentionTrigger?.query);
+ }, [mentionTrigger?.query, onMentionSearchChange]);
+
+ useEffect(
+ () => () => {
+ onMentionSearchChange(undefined);
+ },
+ [onMentionSearchChange],
+ );
+
function submit() {
if (!canSubmit) return;
inputRef.current?.blur();
setFocused(false);
Keyboard.dismiss();
- onSubmit();
+ onSubmit(submitIntent);
+ }
+
+ function changeDraft(nextDraft: string) {
+ onDraftChange(nextDraft, rebaseComposerMentions(draft, nextDraft, mentions, selection));
+ }
+
+ function selectCompletion(completion: (typeof completions)[number]) {
+ const nextDraft = applySlashCompletion(draft, completion);
+ onDraftChange(nextDraft, mentions);
+ setSelection({ end: nextDraft.length, start: nextDraft.length });
+ inputRef.current?.focus();
+ }
+
+ function selectMention(completion: MentionCompletion) {
+ if (!mentionTrigger) return;
+ const next = applyMentionCompletion(draft, mentionTrigger, completion, mentions);
+ onDraftChange(next.draft, next.mentions);
+ setSelection(next.selection);
+ inputRef.current?.focus();
}
return (
@@ -117,14 +216,16 @@ export function SessionComposer({
multiline
numberOfLines={expanded ? 4 : 1}
onBlur={() => setFocused(false)}
- onChangeText={onDraftChange}
+ onChangeText={changeDraft}
onFocus={() => setFocused(true)}
+ onSelectionChange={(event) => setSelection(event.nativeEvent.selection)}
placeholder={active ? "Add a follow-up" : "Ask OpenCode"}
placeholderTextColor={palette.dim}
ref={inputRef}
returnKeyType="default"
scrollEnabled={expanded}
selectionColor={palette.signal}
+ selection={selection}
style={[styles.input, expanded ? styles.inputExpanded : styles.inputCollapsed]}
submitBehavior="newline"
textAlignVertical={expanded ? "top" : "center"}
@@ -135,11 +236,69 @@ export function SessionComposer({
active={active}
canSubmit={canSubmit}
delivery={delivery}
+ disabledHint={submitHint}
onPress={submit}
/>
) : null}
+ {expanded && /^\/[^\s/]*$/.test(draft) ? (
+
+ {completions.length > 0 ? (
+ completions.map((completion) => (
+ selectCompletion(completion)}
+ />
+ ))
+ ) : (
+
+ {completionLoading
+ ? "Loading commands"
+ : completionUnavailable
+ ? "Commands are unavailable"
+ : "No matching commands"}
+
+ )}
+
+ ) : null}
+
+ {expanded && mentionTrigger ? (
+
+ {mentionCompletions.map((completion) => (
+ selectMention(completion)}
+ />
+ ))}
+ {mentionCompletions.length === 0 || mentionLoading || mentionUnavailable ? (
+
+ {mentionLoading
+ ? "Searching files, skills, and agents"
+ : mentionUnavailable
+ ? "Some mention results are unavailable"
+ : "No matching files, skills, or agents"}
+
+ ) : null}
+
+ ) : null}
+
{expanded && active ? (
@@ -313,6 +473,99 @@ function OptionSeparator() {
return ;
}
+function CompletionButton({
+ completion,
+ onPress,
+}: {
+ completion: SlashCompletion;
+ onPress: () => void;
+}) {
+ return (
+ [styles.completion, pressed && styles.pressed]}
+ >
+
+
+ /{completion.name}
+
+
+ COMMAND
+
+
+ {completion.label !== completion.name || completion.description ? (
+
+ {[
+ completion.label !== completion.name ? completion.label : undefined,
+ completion.description,
+ ]
+ .filter(Boolean)
+ .join(" / ")}
+
+ ) : null}
+
+ );
+}
+
+function MentionButton({
+ completion,
+ onPress,
+}: {
+ completion: MentionCompletion;
+ onPress: () => void;
+}) {
+ const value =
+ completion.type === "file"
+ ? completion.path
+ : completion.type === "agent"
+ ? completion.name
+ : completion.id;
+ return (
+ [styles.completion, pressed && styles.pressed]}
+ >
+
+
+ @{value}
+
+
+ {completion.type.toUpperCase()}
+
+
+ {completion.label !== value || completion.description ? (
+
+ {[completion.label !== value ? completion.label : undefined, completion.description]
+ .filter(Boolean)
+ .join(" / ")}
+
+ ) : null}
+
+ );
+}
+
+function mentionCompletionKey(completion: MentionCompletion) {
+ return completion.type === "file"
+ ? `file:${completion.path}`
+ : completion.type === "agent"
+ ? `agent:${completion.name}`
+ : `skill:${completion.id}`;
+}
+
function EmptyResults({ label }: { label: string }) {
return (
@@ -325,16 +578,20 @@ function SendButton({
active,
canSubmit,
delivery,
+ disabledHint,
onPress,
}: {
active: boolean;
canSubmit: boolean;
delivery?: PromptDelivery | undefined;
+ disabledHint?: string | undefined;
onPress: () => void;
}) {
return (
{
+ render(
+ ,
+ );
+
+ expect(screen.getByText(/server may have run this command/i)).toBeOnTheScreen();
+});
+
test("shows and replies to a permission blocking the current session", () => {
render(
{admission.status === "unknown-delivery"
- ? "The server may have admitted this prompt. Check inbox and transcript state before sending it again."
+ ? admission.kind === "command"
+ ? "The server may have run this command. Check the transcript before sending it again."
+ : "The server may have admitted this prompt. Check inbox and transcript state before sending it again."
: "Waiting for the durable inbox item or projected message."}
{admission.status === "unknown-delivery" ? (
diff --git a/apps/mobile/src/screens/use-session-draft.test.tsx b/apps/mobile/src/screens/use-session-draft.test.tsx
index 56a6f6a..24e9da0 100644
--- a/apps/mobile/src/screens/use-session-draft.test.tsx
+++ b/apps/mobile/src/screens/use-session-draft.test.tsx
@@ -5,9 +5,19 @@ import { useSessionDraft } from "./use-session-draft";
const mockDeleteDraft = jest.fn<(...args: unknown[]) => Promise>(async () => undefined);
const mockReadDraft = jest.fn<
- (
- ...args: unknown[]
- ) => Promise<{ content: string; revision: number; updatedAtMs: number } | undefined>
+ (...args: unknown[]) => Promise<
+ | {
+ content: string;
+ mentions: Array<{
+ id: string;
+ mention: { end: number; start: number; text: string };
+ type: "skill";
+ }>;
+ revision: number;
+ updatedAtMs: number;
+ }
+ | undefined
+ >
>(async () => undefined);
const mockWriteDraft = jest.fn<(...args: unknown[]) => Promise>(async () => undefined);
const mockDatabase = {};
@@ -41,18 +51,54 @@ test("does not clear text edited after the submitted draft revision", async () =
expect(hook.result.current.draft).toBe("");
});
-test("restores a durable revision and clears that exact submitted draft", async () => {
- mockReadDraft.mockResolvedValueOnce({ content: "Submitted", revision: 7, updatedAtMs: 10 });
+test("restores a durable revision and structured mentions", async () => {
+ const mentions = [
+ {
+ id: "release",
+ mention: { end: 8, start: 0, text: "@release" },
+ type: "skill" as const,
+ },
+ ];
+ mockReadDraft.mockResolvedValueOnce({
+ content: "@release Submitted",
+ mentions,
+ revision: 7,
+ updatedAtMs: 10,
+ });
const hook = renderHook(() => useSessionDraft("connection-1", "ses_a"));
await waitFor(() => expect(hook.result.current.loaded).toBe(true));
expect(hook.result.current.revision).toBe(7);
+ expect(hook.result.current.mentions).toEqual(mentions);
act(() => hook.result.current.clearDraft(7));
await waitFor(() =>
expect(mockDeleteDraft).toHaveBeenCalledWith(mockDatabase, "connection-1", "ses_a"),
);
expect(hook.result.current.draft).toBe("");
+ expect(hook.result.current.mentions).toEqual([]);
+});
+
+test("writes mention metadata with the encrypted draft", async () => {
+ const hook = renderHook(() => useSessionDraft("connection-1", "ses_a"));
+ await waitFor(() => expect(hook.result.current.loaded).toBe(true));
+ const mentions = [
+ {
+ id: "release",
+ mention: { end: 8, start: 0, text: "@release" },
+ type: "skill" as const,
+ },
+ ];
+
+ act(() => hook.result.current.setDraft("@release ", mentions));
+ await act(async () => {
+ await hook.result.current.persistDraft("@release ", hook.result.current.revision);
+ });
+
+ expect(mockWriteDraft).toHaveBeenCalledWith(
+ mockDatabase,
+ expect.objectContaining({ content: "@release ", mentions }),
+ );
});
test("a late confirmation only clears the draft scope that submitted it", async () => {
diff --git a/apps/mobile/src/screens/use-session-draft.ts b/apps/mobile/src/screens/use-session-draft.ts
index 42fc5af..2164cab 100644
--- a/apps/mobile/src/screens/use-session-draft.ts
+++ b/apps/mobile/src/screens/use-session-draft.ts
@@ -5,6 +5,7 @@ import { AppState } from "react-native";
import {
deleteSessionDraft,
readSessionDraft,
+ type SessionDraftMention,
writeSessionDraft,
} from "../storage/draft-repository";
@@ -14,10 +15,12 @@ export function useSessionDraft(connectionId: string, sessionId: string) {
const db = useSQLiteContext();
const scope = `${connectionId}\u0000${sessionId}`;
const [draft, setDraftState] = useState("");
+ const [mentions, setMentions] = useState([]);
const [error, setError] = useState();
const [loaded, setLoaded] = useState(false);
const [revision, setRevision] = useState(0);
const latestRef = useRef("");
+ const latestMentionsRef = useRef([]);
const revisionRef = useRef(0);
const revisionsByScopeRef = useRef(new Map());
const scopeRef = useRef(scope);
@@ -27,7 +30,7 @@ export function useSessionDraft(connectionId: string, sessionId: string) {
const writeChainRef = useRef>(Promise.resolve());
const enqueue = useCallback(
- (content: string, contentRevision: number) => {
+ (content: string, contentMentions: SessionDraftMention[], contentRevision: number) => {
const operation = writeChainRef.current
.catch(() => undefined)
.then(async () => {
@@ -35,6 +38,7 @@ export function useSessionDraft(connectionId: string, sessionId: string) {
await writeSessionDraft(db, {
connectionId,
content,
+ mentions: contentMentions,
revision: contentRevision,
sessionId,
});
@@ -64,17 +68,21 @@ export function useSessionDraft(connectionId: string, sessionId: string) {
}
if (!dirtyRef.current) return;
dirtyRef.current = false;
- void enqueue(latestRef.current, revisionsByScopeRef.current.get(scope) ?? 0).catch(
- () => undefined,
- );
+ void enqueue(
+ latestRef.current,
+ latestMentionsRef.current,
+ revisionsByScopeRef.current.get(scope) ?? 0,
+ ).catch(() => undefined);
}, [enqueue, scope]);
useEffect(() => {
let active = true;
latestRef.current = "";
+ latestMentionsRef.current = [];
revisionRef.current = revisionsByScopeRef.current.get(scope) ?? 0;
dirtyRef.current = false;
setDraftState("");
+ setMentions([]);
setError(undefined);
setLoaded(false);
setRevision(revisionRef.current);
@@ -84,9 +92,11 @@ export function useSessionDraft(connectionId: string, sessionId: string) {
const content = stored?.content ?? "";
const storedRevision = stored?.revision ?? 0;
latestRef.current = content;
+ latestMentionsRef.current = stored?.mentions ?? [];
revisionRef.current = storedRevision;
revisionsByScopeRef.current.set(scope, storedRevision);
setDraftState(content);
+ setMentions(stored?.mentions ?? []);
setRevision(storedRevision);
})
.catch(() => {
@@ -106,13 +116,18 @@ export function useSessionDraft(connectionId: string, sessionId: string) {
};
}, [connectionId, db, flush, scope, sessionId]);
- function setDraft(content: string) {
+ function setDraft(
+ content: string,
+ contentMentions: SessionDraftMention[] = latestMentionsRef.current,
+ ) {
latestRef.current = content;
+ latestMentionsRef.current = contentMentions;
const nextRevision = (revisionsByScopeRef.current.get(scope) ?? 0) + 1;
revisionRef.current = nextRevision;
revisionsByScopeRef.current.set(scope, nextRevision);
dirtyRef.current = true;
setDraftState(content);
+ setMentions(contentMentions);
setRevision(revisionRef.current);
if (writeTimerRef.current !== null) clearTimeout(writeTimerRef.current);
writeTimerRef.current = setTimeout(flush, draftWriteDelayMs);
@@ -130,12 +145,14 @@ export function useSessionDraft(connectionId: string, sessionId: string) {
}
if (scopeRef.current === scope) {
latestRef.current = "";
+ latestMentionsRef.current = [];
revisionRef.current = nextRevision;
dirtyRef.current = false;
setDraftState("");
+ setMentions([]);
setRevision(nextRevision);
}
- void enqueue("", nextRevision).catch(() => undefined);
+ void enqueue("", [], nextRevision).catch(() => undefined);
},
[enqueue, scope],
);
@@ -153,10 +170,10 @@ export function useSessionDraft(connectionId: string, sessionId: string) {
writeTimerRef.current = null;
dirtyRef.current = false;
}
- await enqueue(content, expectedRevision);
+ await enqueue(content, latestMentionsRef.current, expectedRevision);
},
[enqueue, scope],
);
- return { clearDraft, draft, error, loaded, persistDraft, revision, setDraft };
+ return { clearDraft, draft, error, loaded, mentions, persistDraft, revision, setDraft };
}
diff --git a/apps/mobile/src/screens/use-session-execution.test.tsx b/apps/mobile/src/screens/use-session-execution.test.tsx
index 0417f53..bfa8cf0 100644
--- a/apps/mobile/src/screens/use-session-execution.test.tsx
+++ b/apps/mobile/src/screens/use-session-execution.test.tsx
@@ -29,6 +29,7 @@ const mockAdmissionDb = {
};
const mockBackground = jest.fn<(...args: unknown[]) => Promise>(async () => undefined);
const mockCancel = jest.fn<(...args: unknown[]) => Promise>(async () => undefined);
+const mockCommand = jest.fn<(...args: unknown[]) => Promise>(async () => undefined);
const mockGetMessage = jest.fn<(...args: unknown[]) => Promise>();
const mockInterrupt = jest.fn<(...args: unknown[]) => Promise>(async () => undefined);
const mockListActive = jest.fn<
@@ -37,10 +38,16 @@ const mockListActive = jest.fn<
const mockListAgents = jest.fn<
(...args: unknown[]) => Promise<{ data: []; location: LocationRef }>
>(async () => ({ data: [], location: mockLocation }));
+const mockListCommands = jest.fn<
+ (...args: unknown[]) => Promise<{ data: []; location: LocationRef }>
+>(async () => ({ data: [], location: mockLocation }));
const mockListInbox = jest.fn<(...args: unknown[]) => Promise>(async () => []);
const mockListModels = jest.fn<
(...args: unknown[]) => Promise<{ data: []; location: LocationRef }>
>(async () => ({ data: [], location: mockLocation }));
+const mockListSkills = jest.fn<
+ (...args: unknown[]) => Promise<{ data: []; location: LocationRef }>
+>(async () => ({ data: [], location: mockLocation }));
const mockPrompt = jest.fn<(...args: unknown[]) => Promise>();
const mockQueue = jest.fn<(...args: unknown[]) => Promise>(async () => undefined);
const mockSteer = jest.fn<(...args: unknown[]) => Promise>(async () => undefined);
@@ -61,10 +68,13 @@ jest.mock("@opencode2-mobile/opencode-adapter", () => ({
interruptOpenCodeSession: (...args: unknown[]) => mockInterrupt(...args),
listActiveOpenCodeSessions: (...args: unknown[]) => mockListActive(...args),
listOpenCodeAgents: (...args: unknown[]) => mockListAgents(...args),
+ listOpenCodeCommands: (...args: unknown[]) => mockListCommands(...args),
listOpenCodeModels: (...args: unknown[]) => mockListModels(...args),
listOpenCodeSessionInbox: (...args: unknown[]) => mockListInbox(...args),
+ listOpenCodeSkills: (...args: unknown[]) => mockListSkills(...args),
promptOpenCodeSession: (...args: unknown[]) => mockPrompt(...args),
queueOpenCodeSessionInboxItem: (...args: unknown[]) => mockQueue(...args),
+ runOpenCodeSessionCommand: (...args: unknown[]) => mockCommand(...args),
steerOpenCodeSessionInboxItem: (...args: unknown[]) => mockSteer(...args),
switchOpenCodeSessionAgent: (...args: unknown[]) => mockSwitchAgent(...args),
switchOpenCodeSessionModel: (...args: unknown[]) => mockSwitchModel(...args),
@@ -260,11 +270,95 @@ test("requires explicit active-turn delivery and applies inbox and execution con
await waitFor(() => expect(mockWait).toHaveBeenCalledTimes(1));
});
+test("submits a command through the command endpoint with the existing admission guard", async () => {
+ const clearDraft = jest.fn();
+ const hook = renderExecutionHook({ onAdmissionConfirmed: clearDraft });
+ await waitFor(() => expect(hook.result.current.submitDisabled).toBe(false));
+
+ act(() =>
+ hook.result.current.submit("/review src/æ.ts\nthen tests", {
+ arguments: "src/æ.ts\nthen tests",
+ command: "review",
+ type: "command",
+ }),
+ );
+
+ await waitFor(() => expect(mockCommand).toHaveBeenCalledTimes(1));
+ expect(mockCommand.mock.calls[0]?.[2]).toMatchObject({
+ command: "review",
+ delivery: "steer",
+ text: "src/æ.ts\nthen tests",
+ });
+ expect(mockPrompt).not.toHaveBeenCalled();
+ await waitFor(() => expect(clearDraft).toHaveBeenCalledTimes(1));
+});
+
+test("offers an explicit duplicate-risk retry when a command response is lost", async () => {
+ mockCommand.mockRejectedValueOnce(new TypeError("Network request failed"));
+ const clearDraft = jest.fn();
+ const hook = renderExecutionHook({ onAdmissionConfirmed: clearDraft });
+ await waitFor(() => expect(hook.result.current.submitDisabled).toBe(false));
+
+ act(() =>
+ hook.result.current.submit("/review", {
+ command: "review",
+ type: "command",
+ }),
+ );
+
+ await waitFor(() => expect(hook.result.current.admissions[0]?.status).toBe("unknown-delivery"));
+ expect(hook.result.current.admissions[0]).toMatchObject({
+ kind: "command",
+ retryOffered: true,
+ });
+ expect(hook.result.current.submitDisabled).toBe(true);
+ expect(clearDraft).not.toHaveBeenCalled();
+
+ const admissionID = hook.result.current.admissions[0]?.id ?? "";
+ act(() => hook.result.current.reconcileAdmission(admissionID));
+ await waitFor(() => expect(hook.result.current.error).toMatch(/cannot be identified/i));
+ expect(mockGetMessage).not.toHaveBeenCalled();
+});
+
+test("submits structured file, skill, and agent mentions through the prompt endpoint", async () => {
+ mockPrompt.mockImplementation(async (...args) => {
+ const input = args[2] as { delivery: "queue" | "steer"; id: string };
+ return userInbox(input.id, input.delivery);
+ });
+ const clearDraft = jest.fn();
+ const hook = renderExecutionHook({ onAdmissionConfirmed: clearDraft });
+ await waitFor(() => expect(hook.result.current.submitDisabled).toBe(false));
+
+ act(() =>
+ hook.result.current.submit("Check @src/index.ts with @Explore and @release", {
+ agents: [{ mention: { end: 33, start: 25, text: "@Explore" }, name: "Explore" }],
+ files: [
+ {
+ mention: { end: 19, start: 6, text: "@src/index.ts" },
+ name: "src/index.ts",
+ uri: "file:///workspace/src/index.ts",
+ },
+ ],
+ skills: [{ id: "release", mention: { end: 46, start: 38, text: "@release" } }],
+ type: "prompt",
+ }),
+ );
+
+ await waitFor(() => expect(mockPrompt).toHaveBeenCalledTimes(1));
+ expect(mockPrompt.mock.calls[0]?.[2]).toMatchObject({
+ agents: [{ name: "Explore" }],
+ files: [{ uri: "file:///workspace/src/index.ts" }],
+ skills: [{ id: "release" }],
+ });
+ await waitFor(() => expect(clearDraft).toHaveBeenCalledTimes(1));
+});
+
test("restores an unresolved admission after restart and reconciles it from the inbox", async () => {
const queryClient = createQueryClient();
const admission = {
durable: false,
id: "msg_reconnected",
+ kind: "prompt",
status: "unknown-delivery",
submittedAtMs: 1,
} satisfies PromptAdmission;
@@ -273,6 +367,7 @@ test("restores an unresolved admission after restart and reconciles it from the
admission_id: admission.id,
delivery: null,
draft_revision: 0,
+ submission_kind: "prompt",
status: "submitting",
submitted_at_ms: 1,
},
@@ -294,6 +389,7 @@ test("does not clear a newer draft when a handled admission is restored", async
delivery: "queue",
durable: true,
id: "msg_handled",
+ kind: "prompt",
status: "queued",
submittedAtMs: 1,
} satisfies PromptAdmission;
diff --git a/apps/mobile/src/screens/use-session-execution.ts b/apps/mobile/src/screens/use-session-execution.ts
index 197f3a7..2a5862c 100644
--- a/apps/mobile/src/screens/use-session-execution.ts
+++ b/apps/mobile/src/screens/use-session-execution.ts
@@ -9,12 +9,15 @@ import {
type LocationRef,
listActiveOpenCodeSessions,
listOpenCodeAgents,
+ listOpenCodeCommands,
listOpenCodeModels,
listOpenCodeSessionInbox,
+ listOpenCodeSkills,
type ModelRef,
type OpenCodeClient,
promptOpenCodeSession,
queueOpenCodeSessionInboxItem,
+ runOpenCodeSessionCommand,
type SessionInfo,
type SessionMessageInfo,
steerOpenCodeSessionInboxItem,
@@ -51,6 +54,7 @@ import {
type PromptDelivery,
reconcilePromptAdmission,
} from "./prompt-admission-model";
+import type { ComposerSubmitIntent } from "./session-composer-model";
type SessionExecutionOptions = {
client: OpenCodeClient | undefined;
@@ -126,6 +130,14 @@ export function useSessionExecution({
},
queryKey: openCodeQueryKeys.agents(scopedConnectionId, location),
});
+ const commandsQuery = useQuery({
+ enabled,
+ queryFn: ({ signal }) => {
+ if (!client) throw new Error("CONNECTION_NOT_READY");
+ return listOpenCodeCommands(client, location, { signal });
+ },
+ queryKey: openCodeQueryKeys.commands(scopedConnectionId, location),
+ });
const modelsQuery = useQuery({
enabled,
queryFn: ({ signal }) => {
@@ -134,6 +146,14 @@ export function useSessionExecution({
},
queryKey: openCodeQueryKeys.models(scopedConnectionId, location),
});
+ const skillsQuery = useQuery({
+ enabled,
+ queryFn: ({ signal }) => {
+ if (!client) throw new Error("CONNECTION_NOT_READY");
+ return listOpenCodeSkills(client, location, { signal });
+ },
+ queryKey: openCodeQueryKeys.skills(scopedConnectionId, location),
+ });
const defaultModelQuery = useQuery({
enabled,
queryFn: ({ signal }) => {
@@ -203,6 +223,7 @@ export function useSessionExecution({
const confirmedAdmissions: PromptAdmission[] = [];
updateAdmissions((current) =>
current.map((admission) => {
+ if (admission.kind === "command") return admission;
const inboxItem = inboxById.get(admission.id);
const projectedMessage = projectedMessagesById.get(admission.id);
const serverAdmittedAtMs = inboxItem?.timeCreated ?? projectedMessage?.time.created;
@@ -246,7 +267,7 @@ export function useSessionExecution({
updateAdmissions,
]);
- const promptMutation = useMutation({
+ const submissionMutation = useMutation({
mutationFn: async ({
admission,
requestClient,
@@ -254,6 +275,7 @@ export function useSessionExecution({
persistSubmittedDraft,
requestSessionID,
text,
+ intent,
}: {
admission: PromptAdmission;
admissionKey: QueryKey;
@@ -266,6 +288,7 @@ export function useSessionExecution({
requestSessionID: string;
requestScope: string;
text: string;
+ intent: ComposerSubmitIntent;
}) => {
return withController(controllersRef.current, async (signal) => {
if (admission.draftRevision === undefined) {
@@ -290,17 +313,37 @@ export function useSessionExecution({
} catch {
throw new Error("PROMPT_ADMISSION_PERSISTENCE_FAILED");
}
- return promptOpenCodeSession(
+ if (intent.type === "command") {
+ await runOpenCodeSessionCommand(
+ requestClient,
+ requestSessionID,
+ {
+ ...(intent.agents ? { agents: intent.agents } : {}),
+ command: intent.command,
+ delivery: admission.delivery ?? "steer",
+ ...(intent.files ? { files: intent.files } : {}),
+ ...(intent.skills ? { skills: intent.skills } : {}),
+ text: intent.arguments ?? "",
+ },
+ { signal },
+ );
+ return { type: "command" as const };
+ }
+ const item = await promptOpenCodeSession(
requestClient,
requestSessionID,
{
delivery: admission.delivery ?? "steer",
+ ...(intent.agents ? { agents: intent.agents } : {}),
+ ...(intent.files ? { files: intent.files } : {}),
id: admission.id,
resume: true,
+ ...(intent.skills ? { skills: intent.skills } : {}),
text,
},
{ signal },
);
+ return { item, type: "inbox" as const };
});
},
networkMode: "always",
@@ -313,6 +356,7 @@ export function useSessionExecution({
requestConnectionID,
requestSessionID,
requestScope,
+ intent,
},
) => {
const scopeIsCurrent = executionScopeRef.current === requestScope;
@@ -329,7 +373,7 @@ export function useSessionExecution({
}
if (caught instanceof Error && caught.message === "PROMPT_ADMISSION_PERSISTENCE_FAILED") {
updateAdmissionAt(queryClient, submittedAdmissionKey, admission.id, markPromptCancelled);
- if (scopeIsCurrent) setError("The prompt could not be saved safely and was not sent.");
+ if (scopeIsCurrent) setError("The submission could not be saved safely and was not sent.");
return;
}
const classification = classifyOpenCodeError(caught);
@@ -345,20 +389,27 @@ export function useSessionExecution({
requestSessionID,
admission.id,
).catch(() => undefined);
- if (scopeIsCurrent) setError("The server rejected this prompt before admission.");
+ if (scopeIsCurrent) {
+ setError(
+ intent.type === "command"
+ ? "The server rejected this command."
+ : "The server rejected this prompt before admission.",
+ );
+ }
return;
}
- updateAdmissionAt(
- queryClient,
- submittedAdmissionKey,
- admission.id,
- markPromptDeliveryUnknown,
- );
+ updateAdmissionAt(queryClient, submittedAdmissionKey, admission.id, (current) => {
+ const unknown = markPromptDeliveryUnknown(current);
+ return intent.type === "command" ? markPromptRetryOffered(unknown) : unknown;
+ });
if (scopeIsCurrent) {
+ const label = submissionLabel(intent);
setError(
classification === "CONFLICT"
- ? "The server reported an admission conflict. Check delivery before sending again."
- : "The response was lost. The prompt may have been admitted; check delivery before sending again.",
+ ? `The server reported a ${label} conflict. Check delivery before sending again.`
+ : intent.type === "command"
+ ? "The response was lost. The command may have run; check the transcript before sending again."
+ : "The response was lost. The prompt may have been admitted; check delivery before sending again.",
);
}
void reconcileSubmission();
@@ -367,7 +418,7 @@ export function useSessionExecution({
submittingRef.current = false;
},
onSuccess: (
- item,
+ result,
{
admission,
admissionKey: submittedAdmissionKey,
@@ -378,6 +429,25 @@ export function useSessionExecution({
requestScope,
},
) => {
+ if (result.type === "command") {
+ updateAdmissionsAt(queryClient, submittedAdmissionKey, (current) =>
+ current.filter((candidate) => candidate.id !== admission.id),
+ );
+ void deleteUnresolvedPromptAdmission(
+ db,
+ requestConnectionID,
+ requestSessionID,
+ admission.id,
+ ).catch(() => undefined);
+ if (executionScopeRef.current === requestScope) setError(undefined);
+ confirmAdmission(admission.draftRevision ?? 0);
+ void reconcile();
+ void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(
+ () => undefined,
+ );
+ return;
+ }
+ const item = result.item;
updateAdmissionAt(queryClient, submittedAdmissionKey, admission.id, (current) =>
markPromptConfirmationHandled(markPromptAdmitted(current, item.delivery, item.timeCreated)),
);
@@ -528,14 +598,16 @@ export function useSessionExecution({
requestSessionID: string;
}) => {
if (executionScopeRef.current === requestScope) setBusyAction(action);
- await withController(controllersRef.current, (signal) => {
+ await withController(controllersRef.current, async (signal) => {
if (action === "interrupt") {
- return interruptOpenCodeSession(requestClient, requestSessionID, false, { signal });
+ await interruptOpenCodeSession(requestClient, requestSessionID, false, { signal });
+ return;
}
if (action === "background") {
- return backgroundOpenCodeSession(requestClient, requestSessionID, { signal });
+ await backgroundOpenCodeSession(requestClient, requestSessionID, { signal });
+ return;
}
- return waitForOpenCodeSession(requestClient, requestSessionID, { signal });
+ await waitForOpenCodeSession(requestClient, requestSessionID, { signal });
});
return action;
},
@@ -574,14 +646,14 @@ export function useSessionExecution({
);
}
- function submit(text: string) {
+ function submit(text: string, intent: ComposerSubmitIntent = { type: "prompt" }) {
if (
!client ||
!draftReady ||
!executionStateReady ||
(active && !delivery) ||
submittingRef.current ||
- promptMutation.isPending ||
+ submissionMutation.isPending ||
unresolvedAdmission
) {
return;
@@ -590,9 +662,10 @@ export function useSessionExecution({
const admission = {
...createPromptAdmission(active ? delivery : "steer"),
draftRevision,
+ kind: intent.type,
};
updateAdmissions((current) => [...current, admission]);
- promptMutation.mutate({
+ submissionMutation.mutate({
admission,
admissionKey,
confirmAdmission: onAdmissionConfirmed,
@@ -604,6 +677,7 @@ export function useSessionExecution({
requestSessionID: sessionID,
requestScope: executionScope,
text,
+ intent,
});
}
@@ -619,6 +693,17 @@ export function useSessionExecution({
if (!client) return;
const requestScope = executionScope;
setError(undefined);
+ const admission = admissions.find((candidate) => candidate.id === admissionID);
+ if (admission?.kind === "command") {
+ await reconcile();
+ updateAdmission(admissionID, markPromptRetryOffered);
+ if (executionScopeRef.current === requestScope) {
+ setError(
+ "Command delivery cannot be identified after a lost response. Check the transcript before allowing another attempt.",
+ );
+ }
+ return;
+ }
const [inboxResult, messageResult] = await Promise.allSettled([
withController(controllersRef.current, (signal) =>
listOpenCodeSessionInbox(client, sessionID, { signal }),
@@ -704,16 +789,24 @@ export function useSessionExecution({
admissions,
agents,
busyAction,
+ commands: commandsQuery.data?.data ?? [],
+ completionLoading: commandsQuery.isPending,
+ completionUnavailable: commandsQuery.isError,
defaultModel: defaultModelQuery.data?.data ?? undefined,
delivery,
error,
inbox,
models,
+ mentionAgents: (agentsQuery.data?.data ?? []).filter(
+ (candidate) => !candidate.hidden && candidate.mode !== "primary",
+ ),
+ mentionLoading: agentsQuery.isPending || skillsQuery.isPending,
+ mentionUnavailable: agentsQuery.isError || skillsQuery.isError,
projectedMessageIds,
setDelivery,
submit,
submitDisabled:
- promptMutation.isPending ||
+ submissionMutation.isPending ||
switchAgentMutation.isPending ||
switchModelMutation.isPending ||
unresolvedAdmission ||
@@ -722,6 +815,7 @@ export function useSessionExecution({
!admissionsQuery.isSuccess ||
!draftReady ||
!enabled,
+ skills: skillsQuery.data?.data ?? [],
switchAgent: (agent: string) => {
if (!client) return;
switchAgentMutation.mutate({
@@ -839,6 +933,10 @@ function currentAdmissionRevision(admissions: PromptAdmission[], admissionID: st
return admissions.find((admission) => admission.id === admissionID)?.draftRevision ?? 0;
}
+function submissionLabel(intent: ComposerSubmitIntent) {
+ return intent.type === "prompt" ? "prompt" : intent.type;
+}
+
function unresolvedPromptAdmission(admission: PromptAdmission) {
if (admission.draftRevision === undefined) {
throw new Error("PROMPT_ADMISSION_PERSISTENCE_FAILED");
@@ -848,6 +946,7 @@ function unresolvedPromptAdmission(admission: PromptAdmission) {
draftRevision: admission.draftRevision,
durable: false as const,
id: admission.id,
+ kind: admission.kind,
status:
admission.status === "submitting" ? ("submitting" as const) : ("unknown-delivery" as const),
submittedAtMs: admission.submittedAtMs,
diff --git a/apps/mobile/src/screens/workspace-screen.tsx b/apps/mobile/src/screens/workspace-screen.tsx
index 548650f..cfd303e 100644
--- a/apps/mobile/src/screens/workspace-screen.tsx
+++ b/apps/mobile/src/screens/workspace-screen.tsx
@@ -1,4 +1,5 @@
import {
+ findOpenCodeFiles,
getDefaultOpenCodeLocation,
getOpenCodeLocation,
getOpenCodeSession,
@@ -83,6 +84,7 @@ const maxTranscriptPages = 5;
const iosKeyboardTransparentTopInset = 32;
const liveEdgeThreshold = 2;
const userScrollSettleMs = 160;
+const mentionFileLimit = 20;
const unresolvedLocation = { directory: "__unresolved__" } satisfies LocationRef;
export function WorkspaceScreen({ navigation }: WorkspaceProps) {
@@ -517,6 +519,8 @@ export function SessionScreen({ navigation, route }: SessionProps) {
const [composerDockHeight, setComposerDockHeight] = useState(66);
const [composerDockScreenBottom, setComposerDockScreenBottom] = useState(0);
const [composerKeyboardOffset, setComposerKeyboardOffset] = useState(0);
+ const [mentionSearch, setMentionSearch] = useState();
+ const deferredMentionSearch = useDeferredValue(mentionSearch);
const composerDockRef = useRef(null);
const measuredComposerDockScreenHeightRef = useRef(undefined);
const transcriptListRef = useRef>(null);
@@ -564,6 +568,24 @@ export function SessionScreen({ navigation, route }: SessionProps) {
order: "desc",
}),
});
+ const mentionFilesQuery = useQuery({
+ enabled: Boolean(
+ client && connectionId === routeConnectionId && deferredMentionSearch !== undefined,
+ ),
+ queryFn: ({ signal }) => {
+ if (!client || deferredMentionSearch === undefined) throw new Error("CONNECTION_NOT_READY");
+ return findOpenCodeFiles(client, sessionLocation, deferredMentionSearch, {
+ limit: mentionFileLimit,
+ signal,
+ });
+ },
+ queryKey: openCodeQueryKeys.fileFind(
+ routeConnectionId,
+ sessionLocation,
+ deferredMentionSearch ?? "",
+ mentionFileLimit,
+ ),
+ });
const currentBranch = vcsQuery.data?.data.branch.current;
const branch = currentBranch
? ({
@@ -583,7 +605,7 @@ export function SessionScreen({ navigation, route }: SessionProps) {
connectionId,
draftReady: draft.loaded,
draftRevision: draft.revision,
- location,
+ location: sessionLocation,
messages,
onAdmissionConfirmed: draft.clearDraft,
persistDraft: draft.persistDraft,
@@ -866,6 +888,9 @@ export function SessionScreen({ navigation, route }: SessionProps) {
active={execution.active}
agent={execution.selectedAgent}
agents={execution.agents}
+ commands={execution.commands}
+ completionLoading={execution.completionLoading}
+ completionUnavailable={execution.completionUnavailable}
delivery={execution.delivery}
disabled={execution.submitDisabled || !draft.loaded}
draft={draft.draft}
@@ -873,13 +898,27 @@ export function SessionScreen({ navigation, route }: SessionProps) {
error={execution.error ?? draft.error}
focusOnMount={focusComposer}
largeText={largeText}
+ location={sessionLocation}
+ mentionAgents={execution.mentionAgents}
+ mentionFiles={
+ deferredMentionSearch === mentionSearch ? (mentionFilesQuery.data?.data ?? []) : []
+ }
+ mentionLoading={
+ execution.mentionLoading ||
+ (mentionSearch !== undefined &&
+ (mentionFilesQuery.isPending || deferredMentionSearch !== mentionSearch))
+ }
+ mentions={draft.mentions}
+ mentionUnavailable={execution.mentionUnavailable || mentionFilesQuery.isError}
model={execution.selectedModel}
models={execution.models}
onAgentChange={execution.switchAgent}
onDeliveryChange={execution.setDelivery}
onDraftChange={draft.setDraft}
onModelChange={execution.switchModel}
- onSubmit={() => execution.submit(draft.draft)}
+ onMentionSearchChange={setMentionSearch}
+ onSubmit={(intent) => execution.submit(draft.draft, intent)}
+ skills={execution.skills}
/>
);
diff --git a/apps/mobile/src/state/connection-event-query-bridge.test.ts b/apps/mobile/src/state/connection-event-query-bridge.test.ts
index ec9b789..4871f30 100644
--- a/apps/mobile/src/state/connection-event-query-bridge.test.ts
+++ b/apps/mobile/src/state/connection-event-query-bridge.test.ts
@@ -106,6 +106,32 @@ test("reconciles one transcript after execution completes", () => {
queryClient.clear();
});
+test.each(["session.step.streamed", "session.message.content.updated"] as const)(
+ "%s reconciles only the affected transcript",
+ (type) => {
+ const queryClient = new QueryClient();
+ const invalidate = jest.spyOn(queryClient, "invalidateQueries");
+ const bridge = new ConnectionEventQueryBridge(queryClient, "connection-1", (callback) =>
+ callback(),
+ );
+ const location = { directory: "/workspace" };
+ const affectedKey = openCodeQueryKeys.messages("connection-1", location, "session-1", {});
+ const otherKey = openCodeQueryKeys.messages("connection-1", location, "session-2", {});
+ queryClient.setQueryData(affectedKey, []);
+ queryClient.setQueryData(otherKey, []);
+
+ bridge.apply(messageReconciliationEvent(type));
+
+ expect(invalidate).toHaveBeenCalledTimes(1);
+ const predicate = invalidate.mock.calls[0]?.[0]?.predicate;
+ const affected = queryClient.getQueryCache().find({ queryKey: affectedKey });
+ const other = queryClient.getQueryCache().find({ queryKey: otherKey });
+ expect(affected && predicate?.(affected)).toBe(true);
+ expect(other && predicate?.(other)).toBe(false);
+ queryClient.clear();
+ },
+);
+
test("does not write active-session state for transcript deltas", () => {
const queryClient = new QueryClient();
const setQueryData = jest.spyOn(queryClient, "setQueryData");
@@ -512,6 +538,22 @@ function sessionExecutionSucceededEvent(id: string) {
} satisfies OpenCodeEvent;
}
+function messageReconciliationEvent(
+ type: "session.step.streamed" | "session.message.content.updated",
+) {
+ return {
+ created: 1,
+ data:
+ type === "session.step.streamed"
+ ? { assistantMessageID: "message-1", sessionID: "session-1" }
+ : { content: [], messageID: "message-1", sessionID: "session-1" },
+ durable: { aggregateID: "session-1", seq: 1, version: 1 as const },
+ id: `event-${type}`,
+ location: { directory: "/workspace" },
+ type,
+ } as OpenCodeEvent;
+}
+
function transcriptEvent(id: string, type: string, data: Record) {
return {
created: 1,
diff --git a/apps/mobile/src/state/connection-event-query-bridge.ts b/apps/mobile/src/state/connection-event-query-bridge.ts
index bf5c25e..bb31e35 100644
--- a/apps/mobile/src/state/connection-event-query-bridge.ts
+++ b/apps/mobile/src/state/connection-event-query-bridge.ts
@@ -339,9 +339,11 @@ const messageReconciliationEventTypes = new Set([
"session.execution.interrupted",
"session.compaction.ended",
"session.compaction.failed",
+ "session.message.content.updated",
"session.revert.staged",
"session.revert.cleared",
"session.revert.committed",
+ "session.step.streamed",
]);
function addActiveSession(sessions: Record, sessionId: string) {
diff --git a/apps/mobile/src/state/open-code-query-keys.test.ts b/apps/mobile/src/state/open-code-query-keys.test.ts
index 6ec47ad..86aab58 100644
--- a/apps/mobile/src/state/open-code-query-keys.test.ts
+++ b/apps/mobile/src/state/open-code-query-keys.test.ts
@@ -34,6 +34,37 @@ test("does not collide across connections or workspace locations", () => {
expect(first).not.toEqual(workspace);
});
+test("scopes composer catalogs and file searches to the exact location", () => {
+ const location = { directory: "/workspace", workspaceID: "wrk_test" };
+
+ expect(openCodeQueryKeys.commands("connection-1", location)).toEqual([
+ "opencode",
+ "connection-1",
+ "location",
+ "/workspace",
+ "wrk_test",
+ "commands",
+ ]);
+ expect(openCodeQueryKeys.skills("connection-1", location)).toEqual([
+ "opencode",
+ "connection-1",
+ "location",
+ "/workspace",
+ "wrk_test",
+ "skills",
+ ]);
+ expect(openCodeQueryKeys.fileFind("connection-1", location, "src/index", 20)).toEqual([
+ "opencode",
+ "connection-1",
+ "location",
+ "/workspace",
+ "wrk_test",
+ "file-find",
+ "src/index",
+ 20,
+ ]);
+});
+
test("separates working-tree and branch diffs within one location", () => {
const location = { directory: "/workspace", workspaceID: "wrk_test" };
expect(openCodeQueryKeys.vcsDiff("connection-1", location, "working")).toEqual([
diff --git a/apps/mobile/src/state/open-code-query-keys.ts b/apps/mobile/src/state/open-code-query-keys.ts
index abdebd1..342d500 100644
--- a/apps/mobile/src/state/open-code-query-keys.ts
+++ b/apps/mobile/src/state/open-code-query-keys.ts
@@ -19,6 +19,9 @@ export const openCodeQueryKeys = {
agents(connectionId: string, location: LocationRef) {
return [...locationKey(connectionId, location), "agents"] as const;
},
+ commands(connectionId: string, location: LocationRef) {
+ return [...locationKey(connectionId, location), "commands"] as const;
+ },
connection(connectionId: string) {
return connectionKey(connectionId);
},
@@ -28,6 +31,9 @@ export const openCodeQueryKeys = {
forms(connectionId: string, location: LocationRef) {
return [...locationKey(connectionId, location), "forms"] as const;
},
+ fileFind(connectionId: string, location: LocationRef, query: string, limit: number) {
+ return [...locationKey(connectionId, location), "file-find", query, limit] as const;
+ },
followedProjectSessions(
connectionId: string,
projectIds: readonly string[],
@@ -159,6 +165,9 @@ export const openCodeQueryKeys = {
: parameters.parentID,
] as const;
},
+ skills(connectionId: string, location: LocationRef) {
+ return [...locationKey(connectionId, location), "skills"] as const;
+ },
};
function connectionKey(connectionId: string) {
diff --git a/apps/mobile/src/storage/database.test.ts b/apps/mobile/src/storage/database.test.ts
index 59f3ceb..31bb99d 100644
--- a/apps/mobile/src/storage/database.test.ts
+++ b/apps/mobile/src/storage/database.test.ts
@@ -12,7 +12,7 @@ test("creates the current mobile database schema", async () => {
await migrateMobileDatabase(db);
- expect(execAsync).toHaveBeenCalledTimes(9);
+ expect(execAsync).toHaveBeenCalledTimes(11);
expect(execAsync.mock.calls[1]?.[0]).toContain("CREATE TABLE IF NOT EXISTS connection_profiles");
expect(execAsync.mock.calls[2]?.[0]).toContain("CREATE TABLE IF NOT EXISTS app_preferences");
expect(execAsync.mock.calls[3]?.[0]).toContain("CREATE TABLE IF NOT EXISTS session_drafts");
@@ -31,6 +31,10 @@ test("creates the current mobile database schema", async () => {
expect(execAsync.mock.calls[7]?.[0]).toContain("PRAGMA user_version = 7");
expect(execAsync.mock.calls[8]?.[0]).toContain("pending_notification_revocations");
expect(execAsync.mock.calls[8]?.[0]).toContain("PRAGMA user_version = 8");
+ expect(execAsync.mock.calls[9]?.[0]).toContain("ADD COLUMN payload_version");
+ expect(execAsync.mock.calls[9]?.[0]).toContain("PRAGMA user_version = 9");
+ expect(execAsync.mock.calls[10]?.[0]).toContain("ADD COLUMN submission_kind");
+ expect(execAsync.mock.calls[10]?.[0]).toContain("PRAGMA user_version = 10");
});
test("migrates an existing profile database to app-lock preferences", async () => {
@@ -42,7 +46,7 @@ test("migrates an existing profile database to app-lock preferences", async () =
await migrateMobileDatabase(db);
- expect(execAsync).toHaveBeenCalledTimes(8);
+ expect(execAsync).toHaveBeenCalledTimes(10);
expect(execAsync.mock.calls[1]?.[0]).toContain("CREATE TABLE IF NOT EXISTS app_preferences");
expect(execAsync.mock.calls[1]?.[0]).not.toContain("connection_profiles");
expect(execAsync.mock.calls[2]?.[0]).toContain("CREATE TABLE IF NOT EXISTS session_drafts");
@@ -61,7 +65,7 @@ test("migrates app-lock databases to encrypted draft storage", async () => {
await migrateMobileDatabase(db);
- expect(execAsync).toHaveBeenCalledTimes(7);
+ expect(execAsync).toHaveBeenCalledTimes(9);
expect(execAsync.mock.calls[1]?.[0]).toContain("ciphertext BLOB NOT NULL");
expect(execAsync.mock.calls[1]?.[0]).toContain("ON DELETE CASCADE");
expect(execAsync.mock.calls[1]?.[0]).not.toContain("app_preferences");
@@ -80,7 +84,7 @@ test("migrates encrypted draft databases to unresolved admission storage", async
await migrateMobileDatabase(db);
- expect(execAsync).toHaveBeenCalledTimes(6);
+ expect(execAsync).toHaveBeenCalledTimes(8);
expect(execAsync.mock.calls[1]?.[0]).toContain("status IN ('submitting', 'unknown-delivery')");
expect(execAsync.mock.calls[1]?.[0]).toContain("ADD COLUMN revision");
expect(execAsync.mock.calls[2]?.[0]).toContain("followed_projects");
@@ -96,7 +100,7 @@ test("migrates admission databases to followed project preferences", async () =>
await migrateMobileDatabase(db);
- expect(execAsync).toHaveBeenCalledTimes(5);
+ expect(execAsync).toHaveBeenCalledTimes(7);
expect(execAsync.mock.calls[1]?.[0]).toContain("followed_project_preferences");
expect(execAsync.mock.calls[1]?.[0]).toContain("PRIMARY KEY (connection_id, project_id)");
expect(execAsync.mock.calls[1]?.[0]).toContain("UNIQUE (connection_id, position)");
@@ -112,7 +116,7 @@ test("migrates followed project databases to notification pairing storage", asyn
await migrateMobileDatabase(db);
- expect(execAsync).toHaveBeenCalledTimes(4);
+ expect(execAsync).toHaveBeenCalledTimes(6);
expect(execAsync.mock.calls[1]?.[0]).toContain("pending_notification_secret_deletions");
expect(execAsync.mock.calls[1]?.[0]).toContain("BEGIN IMMEDIATE");
expect(execAsync.mock.calls[1]?.[0]).toContain("COMMIT");
@@ -128,7 +132,7 @@ test("migrates notification pairings to handled event replay storage", async ()
await migrateMobileDatabase(db);
- expect(execAsync).toHaveBeenCalledTimes(3);
+ expect(execAsync).toHaveBeenCalledTimes(5);
expect(execAsync.mock.calls[1]?.[0]).toContain("handled_notification_events");
expect(execAsync.mock.calls[1]?.[0]).toContain("ON DELETE CASCADE");
expect(execAsync.mock.calls[1]?.[0]).toContain("COMMIT");
@@ -144,16 +148,50 @@ test("migrates handled events to pending notification revocation storage", async
await migrateMobileDatabase(db);
- expect(execAsync).toHaveBeenCalledTimes(2);
+ expect(execAsync).toHaveBeenCalledTimes(4);
expect(execAsync.mock.calls[1]?.[0]).toContain("pending_notification_revocations");
expect(execAsync.mock.calls[1]?.[0]).toContain("PRAGMA user_version = 8");
expect(execAsync.mock.calls[1]?.[0]).toContain("COMMIT");
});
+test("migrates legacy encrypted drafts to explicit payload versioning", async () => {
+ const execAsync = jest.fn<(source: string) => Promise>(async () => undefined);
+ const db = {
+ execAsync,
+ getFirstAsync: jest.fn(async () => ({ user_version: 8 })),
+ } as unknown as SQLiteDatabase;
+
+ await migrateMobileDatabase(db);
+
+ expect(execAsync).toHaveBeenCalledTimes(3);
+ expect(execAsync.mock.calls[1]?.[0]).toContain("ADD COLUMN payload_version");
+ expect(execAsync.mock.calls[1]?.[0]).toContain("DEFAULT 1");
+ expect(execAsync.mock.calls[1]?.[0]).toContain("BEGIN IMMEDIATE");
+ expect(execAsync.mock.calls[1]?.[0]).toContain("PRAGMA user_version = 9");
+ expect(execAsync.mock.calls[1]?.[0]).toContain("COMMIT");
+});
+
+test("migrates admission recovery metadata to distinguish commands", async () => {
+ const execAsync = jest.fn<(source: string) => Promise>(async () => undefined);
+ const db = {
+ execAsync,
+ getFirstAsync: jest.fn(async () => ({ user_version: 9 })),
+ } as unknown as SQLiteDatabase;
+
+ await migrateMobileDatabase(db);
+
+ expect(execAsync).toHaveBeenCalledTimes(2);
+ expect(execAsync.mock.calls[1]?.[0]).toContain("ADD COLUMN submission_kind");
+ expect(execAsync.mock.calls[1]?.[0]).toContain("DEFAULT 'prompt'");
+ expect(execAsync.mock.calls[1]?.[0]).toContain("BEGIN IMMEDIATE");
+ expect(execAsync.mock.calls[1]?.[0]).toContain("PRAGMA user_version = 10");
+ expect(execAsync.mock.calls[1]?.[0]).toContain("COMMIT");
+});
+
test("rejects a database created by a newer app", async () => {
const db = {
execAsync: jest.fn(async () => undefined),
- getFirstAsync: jest.fn(async () => ({ user_version: 9 })),
+ getFirstAsync: jest.fn(async () => ({ user_version: 11 })),
} as unknown as SQLiteDatabase;
await expect(migrateMobileDatabase(db)).rejects.toThrow("DATABASE_VERSION_TOO_NEW");
diff --git a/apps/mobile/src/storage/database.ts b/apps/mobile/src/storage/database.ts
index fac6345..8aa98c6 100644
--- a/apps/mobile/src/storage/database.ts
+++ b/apps/mobile/src/storage/database.ts
@@ -1,7 +1,7 @@
import type { SQLiteDatabase } from "expo-sqlite";
export const mobileDatabaseName = "opencode-mobile.db";
-export const mobileDatabaseSchemaVersion = 8;
+export const mobileDatabaseSchemaVersion = 10;
const maxDraftCiphertextBytes = 256 * 1024 + 16;
@@ -196,4 +196,36 @@ export async function migrateMobileDatabase(db: SQLiteDatabase) {
throw caught;
}
}
+
+ if (version < 9) {
+ try {
+ await db.execAsync(`
+ BEGIN IMMEDIATE;
+ ALTER TABLE session_drafts
+ ADD COLUMN payload_version INTEGER NOT NULL DEFAULT 1
+ CHECK (payload_version IN (1, 2));
+ PRAGMA user_version = 9;
+ COMMIT;
+ `);
+ } catch (caught) {
+ await db.execAsync("ROLLBACK;").catch(() => undefined);
+ throw caught;
+ }
+ }
+
+ if (version < 10) {
+ try {
+ await db.execAsync(`
+ BEGIN IMMEDIATE;
+ ALTER TABLE unresolved_prompt_admissions
+ ADD COLUMN submission_kind TEXT NOT NULL DEFAULT 'prompt'
+ CHECK (submission_kind IN ('command', 'prompt'));
+ PRAGMA user_version = 10;
+ COMMIT;
+ `);
+ } catch (caught) {
+ await db.execAsync("ROLLBACK;").catch(() => undefined);
+ throw caught;
+ }
+ }
}
diff --git a/apps/mobile/src/storage/draft-repository.test.ts b/apps/mobile/src/storage/draft-repository.test.ts
index 13cc08e..84f286a 100644
--- a/apps/mobile/src/storage/draft-repository.test.ts
+++ b/apps/mobile/src/storage/draft-repository.test.ts
@@ -13,6 +13,7 @@ jest.mock("../security/draft-key-store", () => ({
readConnectionDraftKey: async () => mockDraftKey,
}));
+import { encryptSessionDraft } from "./draft-crypto";
import {
cleanupPendingDraftKeyDeletions,
deleteSessionDraft,
@@ -39,14 +40,21 @@ test("writes only encrypted draft bytes to SQLite and reads them back", async ()
await writeSessionDraft(writeDb, {
connectionId: "connection-1",
content: "private draft",
+ mentions: [
+ {
+ id: "release",
+ mention: { end: 8, start: 0, text: "@release" },
+ type: "skill",
+ },
+ ],
revision: 7,
sessionId: "session-1",
});
expect(JSON.stringify(runAsync.mock.calls)).not.toContain("private draft");
const parameters = runAsync.mock.calls[0];
- const nonce = parameters?.[5];
- const ciphertext = parameters?.[6];
+ const nonce = parameters?.[6];
+ const ciphertext = parameters?.[7];
expect(nonce).toBeInstanceOf(Uint8Array);
expect(ciphertext).toBeInstanceOf(Uint8Array);
expect(String(parameters?.[0])).toContain("excluded.revision >= session_drafts.revision");
@@ -56,6 +64,7 @@ test("writes only encrypted draft bytes to SQLite and reads them back", async ()
getFirstAsync: jest.fn(async () => ({
ciphertext,
nonce,
+ payload_version: 2,
revision: 7,
schema_version: 1,
updated_at_ms: 42,
@@ -63,11 +72,40 @@ test("writes only encrypted draft bytes to SQLite and reads them back", async ()
} as unknown as SQLiteDatabase;
await expect(readSessionDraft(readDb, "connection-1", "session-1")).resolves.toEqual({
content: "private draft",
+ mentions: [
+ {
+ id: "release",
+ mention: { end: 8, start: 0, text: "@release" },
+ type: "skill",
+ },
+ ],
revision: 7,
updatedAtMs: 42,
});
});
+test("reads pre-mention encrypted drafts as plain text", async () => {
+ const legacyDraft = 'opencode-mobile-draft:2\n{"content":"still plain text"}';
+ const encrypted = encryptSessionDraft(legacyDraft, mockDraftKey, "connection-1", "session-1");
+ const db = {
+ getFirstAsync: jest.fn(async () => ({
+ ciphertext: encrypted.ciphertext,
+ nonce: encrypted.nonce,
+ payload_version: 1,
+ revision: 3,
+ schema_version: 1,
+ updated_at_ms: 42,
+ })),
+ } as unknown as SQLiteDatabase;
+
+ await expect(readSessionDraft(db, "connection-1", "session-1")).resolves.toEqual({
+ content: legacyDraft,
+ mentions: [],
+ revision: 3,
+ updatedAtMs: 42,
+ });
+});
+
test("deletes one connection/session draft", async () => {
const runAsync = jest.fn(async (..._parameters: unknown[]) => ({
changes: 1,
diff --git a/apps/mobile/src/storage/draft-repository.ts b/apps/mobile/src/storage/draft-repository.ts
index 05fa3f4..ec91cbf 100644
--- a/apps/mobile/src/storage/draft-repository.ts
+++ b/apps/mobile/src/storage/draft-repository.ts
@@ -8,10 +8,12 @@ import {
import { decryptSessionDraft, encryptSessionDraft } from "./draft-crypto";
const draftSchemaVersion = 1;
+const draftPayloadVersion = 2;
type SessionDraftRow = {
ciphertext: Uint8Array;
nonce: Uint8Array;
+ payload_version: number;
revision: number;
schema_version: number;
updated_at_ms: number;
@@ -19,13 +21,20 @@ type SessionDraftRow = {
export type SessionDraft = {
content: string;
+ mentions: SessionDraftMention[];
revision: number;
updatedAtMs: number;
};
+export type SessionDraftMention =
+ | { mention: { end: number; start: number; text: string }; path: string; type: "file" }
+ | { mention: { end: number; start: number; text: string }; name: string; type: "agent" }
+ | { id: string; mention: { end: number; start: number; text: string }; type: "skill" };
+
export type WriteSessionDraftInput = {
connectionId: string;
content: string;
+ mentions?: SessionDraftMention[];
revision: number;
sessionId: string;
};
@@ -38,13 +47,20 @@ export async function writeSessionDraft(db: SQLiteDatabase, input: WriteSessionD
if (!profile) throw new Error("CONNECTION_PROFILE_NOT_FOUND");
const key = await getOrCreateConnectionDraftKey(input.connectionId);
- const encrypted = encryptSessionDraft(input.content, key, input.connectionId, input.sessionId);
+ const encrypted = encryptSessionDraft(
+ encodeDraftPayload(input.content, input.mentions ?? []),
+ key,
+ input.connectionId,
+ input.sessionId,
+ );
const result = await db.runAsync(
`INSERT INTO session_drafts (
- connection_id, session_id, schema_version, revision, nonce, ciphertext, updated_at_ms
- ) VALUES (?, ?, ?, ?, ?, ?, ?)
+ connection_id, session_id, schema_version, payload_version, revision, nonce, ciphertext,
+ updated_at_ms
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(connection_id, session_id) DO UPDATE SET
schema_version = excluded.schema_version,
+ payload_version = excluded.payload_version,
revision = excluded.revision,
nonce = excluded.nonce,
ciphertext = excluded.ciphertext,
@@ -53,6 +69,7 @@ export async function writeSessionDraft(db: SQLiteDatabase, input: WriteSessionD
input.connectionId,
input.sessionId,
draftSchemaVersion,
+ draftPayloadVersion,
input.revision,
encrypted.nonce,
encrypted.ciphertext,
@@ -78,7 +95,7 @@ export async function readSessionDraft(
sessionId: string,
): Promise {
const row = await db.getFirstAsync(
- `SELECT schema_version, revision, nonce, ciphertext, updated_at_ms
+ `SELECT schema_version, payload_version, revision, nonce, ciphertext, updated_at_ms
FROM session_drafts WHERE connection_id = ? AND session_id = ?`,
connectionId,
sessionId,
@@ -86,6 +103,7 @@ export async function readSessionDraft(
if (!row) return undefined;
if (
row.schema_version !== draftSchemaVersion ||
+ (row.payload_version !== 1 && row.payload_version !== draftPayloadVersion) ||
!Number.isInteger(row.revision) ||
row.revision < 0 ||
!(row.nonce instanceof Uint8Array) ||
@@ -95,18 +113,71 @@ export async function readSessionDraft(
}
const key = await readConnectionDraftKey(connectionId);
+ const plaintext = decryptSessionDraft(
+ { ciphertext: row.ciphertext, nonce: row.nonce },
+ key,
+ connectionId,
+ sessionId,
+ );
+ const payload =
+ row.payload_version === draftPayloadVersion
+ ? decodeDraftPayload(plaintext)
+ : { content: plaintext, mentions: [] };
return {
- content: decryptSessionDraft(
- { ciphertext: row.ciphertext, nonce: row.nonce },
- key,
- connectionId,
- sessionId,
- ),
+ content: payload.content,
+ mentions: payload.mentions,
revision: row.revision,
updatedAtMs: row.updated_at_ms,
};
}
+function encodeDraftPayload(content: string, mentions: SessionDraftMention[]) {
+ return JSON.stringify({ content, mentions });
+}
+
+function decodeDraftPayload(value: string): { content: string; mentions: SessionDraftMention[] } {
+ try {
+ const decoded: unknown = JSON.parse(value);
+ if (
+ !isRecord(decoded) ||
+ typeof decoded.content !== "string" ||
+ !Array.isArray(decoded.mentions) ||
+ !decoded.mentions.every(isDraftMention)
+ ) {
+ throw new Error("INVALID_STORED_DRAFT");
+ }
+ return { content: decoded.content, mentions: decoded.mentions };
+ } catch (caught) {
+ if (caught instanceof Error && caught.message === "INVALID_STORED_DRAFT") throw caught;
+ throw new Error("INVALID_STORED_DRAFT");
+ }
+}
+
+function isDraftMention(value: unknown): value is SessionDraftMention {
+ if (!isRecord(value) || !isRecord(value.mention)) return false;
+ const start = value.mention.start;
+ const end = value.mention.end;
+ if (
+ typeof start !== "number" ||
+ typeof end !== "number" ||
+ !Number.isInteger(start) ||
+ !Number.isInteger(end) ||
+ start < 0 ||
+ end < start ||
+ typeof value.mention.text !== "string"
+ ) {
+ return false;
+ }
+ if (value.type === "file") return typeof value.path === "string" && Boolean(value.path);
+ if (value.type === "agent") return typeof value.name === "string" && Boolean(value.name);
+ if (value.type === "skill") return typeof value.id === "string" && Boolean(value.id);
+ return false;
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null;
+}
+
export async function deleteSessionDraft(
db: SQLiteDatabase,
connectionId: string,
diff --git a/apps/mobile/src/storage/prompt-admission-repository.test.ts b/apps/mobile/src/storage/prompt-admission-repository.test.ts
index b84486f..82d9586 100644
--- a/apps/mobile/src/storage/prompt-admission-repository.test.ts
+++ b/apps/mobile/src/storage/prompt-admission-repository.test.ts
@@ -20,6 +20,7 @@ test("stores only content-free unresolved admission metadata", async () => {
draftRevision: 3,
durable: false,
id: "msg_test",
+ kind: "command",
status: "unknown-delivery",
submittedAtMs: 10,
});
@@ -32,6 +33,7 @@ test("stores only content-free unresolved admission metadata", async () => {
"unknown-delivery",
"queue",
3,
+ "command",
10,
]);
expect(String(runAsync.mock.calls[1]?.[0])).toContain("LIMIT 20");
@@ -67,6 +69,7 @@ test("decodes unresolved admission rows and deletes a confirmed ID", async () =>
admission_id: "msg_test",
delivery: "steer",
draft_revision: 2,
+ submission_kind: "prompt",
status: "submitting",
submitted_at_ms: 10,
},
@@ -80,6 +83,7 @@ test("decodes unresolved admission rows and deletes a confirmed ID", async () =>
draftRevision: 2,
durable: false,
id: "msg_test",
+ kind: "prompt",
status: "unknown-delivery",
submittedAtMs: 10,
},
diff --git a/apps/mobile/src/storage/prompt-admission-repository.ts b/apps/mobile/src/storage/prompt-admission-repository.ts
index d42149c..a9d3a8c 100644
--- a/apps/mobile/src/storage/prompt-admission-repository.ts
+++ b/apps/mobile/src/storage/prompt-admission-repository.ts
@@ -5,6 +5,7 @@ export type PersistedPromptAdmission = {
draftRevision: number;
durable: false;
id: string;
+ kind: "command" | "prompt";
status: "submitting" | "unknown-delivery";
submittedAtMs: number;
};
@@ -13,6 +14,7 @@ type PromptAdmissionRow = {
admission_id: string;
delivery: "queue" | "steer" | null;
draft_revision: number;
+ submission_kind: "command" | "prompt";
status: "submitting" | "unknown-delivery";
submitted_at_ms: number;
};
@@ -23,7 +25,7 @@ export async function listUnresolvedPromptAdmissions(
sessionId: string,
): Promise {
const rows = await db.getAllAsync(
- `SELECT admission_id, delivery, draft_revision, status, submitted_at_ms
+ `SELECT admission_id, delivery, draft_revision, status, submission_kind, submitted_at_ms
FROM unresolved_prompt_admissions
WHERE connection_id = ? AND session_id = ?
ORDER BY submitted_at_ms ASC
@@ -36,6 +38,7 @@ export async function listUnresolvedPromptAdmissions(
draftRevision: row.draft_revision,
durable: false,
id: row.admission_id,
+ kind: row.submission_kind,
status: "unknown-delivery",
submittedAtMs: row.submitted_at_ms,
}));
@@ -51,12 +54,13 @@ export async function writeUnresolvedPromptAdmission(
await txn.runAsync(
`INSERT INTO unresolved_prompt_admissions (
connection_id, session_id, admission_id, schema_version, status,
- delivery, draft_revision, submitted_at_ms
- ) VALUES (?, ?, ?, 1, ?, ?, ?, ?)
+ delivery, draft_revision, submission_kind, submitted_at_ms
+ ) VALUES (?, ?, ?, 1, ?, ?, ?, ?, ?)
ON CONFLICT(connection_id, session_id, admission_id) DO UPDATE SET
status = excluded.status,
delivery = excluded.delivery,
draft_revision = excluded.draft_revision,
+ submission_kind = excluded.submission_kind,
submitted_at_ms = excluded.submitted_at_ms`,
connectionId,
sessionId,
@@ -64,6 +68,7 @@ export async function writeUnresolvedPromptAdmission(
admission.status,
admission.delivery ?? null,
admission.draftRevision,
+ admission.kind,
admission.submittedAtMs,
);
await txn.runAsync(
diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md
index fbca940..947093a 100644
--- a/docs/COMPATIBILITY.md
+++ b/docs/COMPATIBILITY.md
@@ -12,6 +12,39 @@ the fail-closed database backup-exclusion startup guard. Statements marked
pending in older dated entries describe the status at the time of that probe;
later entries supersede them.
+## 2026-08-27: beta 18387 generated-client upgrade
+
+### Stack
+
+- OpenCode client, protocol, and schema: `0.0.0-beta-18387`
+- OpenCode notification plugin: `0.0.0-beta-18387`
+- Installed OpenCode CLI: `0.0.0-beta-18387`
+- Shared service used for the catalog probe: `0.0.0-beta-18371`
+
+### Results
+
+| Probe | Result |
+| --- | --- |
+| Install matching client, protocol, schema, AI, and plugin packages | Pass |
+| Compile the adapter, mobile app, and notification plugin against the generated types | Pass |
+| List commands at the exact repository location | Pass |
+| Receive built-in and configured command records with names and optional descriptions | Pass |
+| Send command arguments as `text` and accept the generated client's `204` response | Pass in the deterministic fake API |
+| Keep a lost command response behind an explicit duplicate-risk retry guard | Pass |
+| Route beta 18387 step-streamed and message-content events to exact-session reconciliation | Pass |
+| Decode the beta 18387 interrupt response | Pass in the deterministic fake API |
+| Run all 292 mobile tests | Pass |
+| Export iOS and Android Hermes bundles | Pass |
+| Run Expo Doctor | Pass, 18/18 checks |
+
+Beta 18387 removes command templates from `Command.Info`. The command endpoint
+also replaces `arguments`, client message IDs, and inbox responses with `text`
+and a `204` response. Prompt admission IDs remain stable and reconcilable.
+Command delivery cannot be identified after a response is lost, so the app
+refreshes server state, preserves the draft, and requires an explicit retry.
+The catalog probe recorded no address, credential, identifier, path, prompt, or
+server content.
+
## 2026-08-24: local beta 18050 interaction-scope probes
### Result
diff --git a/docs/NOTIFICATIONS.md b/docs/NOTIFICATIONS.md
index b3bcf74..2a769d5 100644
--- a/docs/NOTIFICATIONS.md
+++ b/docs/NOTIFICATIONS.md
@@ -95,7 +95,7 @@ OpenCode.
}
```
-The package is pinned to `@opencode-ai/plugin@0.0.0-beta-18050`. It subscribes to
+The package is pinned to `@opencode-ai/plugin@0.0.0-beta-18387`. It subscribes to
`permission.asked`, `permission.replied`, `form.created`, `form.replied`,
`form.cancelled`, and `session.execution.succeeded`. It converts permission
actions to a finite category before storing a sanitized retry queue in plugin
diff --git a/docs/PUSH_AGENT_RUNBOOK.md b/docs/PUSH_AGENT_RUNBOOK.md
index df8fcd9..77d23a6 100644
--- a/docs/PUSH_AGENT_RUNBOOK.md
+++ b/docs/PUSH_AGENT_RUNBOOK.md
@@ -77,10 +77,10 @@ host and identify the first boundary that remains unverified.
- Linux with a user-level systemd session.
- OpenCode V2 running under the same account as the broker.
-- OpenCode `0.0.0-beta-18286`, the server version used for the latest physical
- probe. The plugin dependency and mobile API contract remain pinned to beta
- 18050. If the installed server differs, report it and ask whether the internal
- deployment owner has approved that version before continuing.
+- OpenCode `0.0.0-beta-18387`, matching the plugin dependency and mobile API
+ contract. The latest physical notification probe used beta 18286. If the
+ installed server differs, report it and ask whether the internal deployment
+ owner has approved that version before continuing.
- Node `26.7.0`, pnpm `11.21.0`, Git, and `fnm` or another way to run the pinned
Node release.
- A phone-reachable broker origin. Prefer HTTPS. Private Tailscale or LAN HTTP is
diff --git a/docs/SPEC.md b/docs/SPEC.md
index 9820656..f0d32a0 100644
--- a/docs/SPEC.md
+++ b/docs/SPEC.md
@@ -21,7 +21,7 @@ contracts or create a second provider abstraction.
mobile application.
- The event stream is volatile. Events can be lost during disconnection,
background suspension, server restart, or overflow.
-- The installed client is `0.0.0-beta-18050`. Recheck the `@beta` tag before
+- The installed client is `0.0.0-beta-18387`. Recheck the `@beta` tag before
each integration milestone, but do not reject a server only because its
application version differs.
- A server reachable at `127.0.0.1` on a development computer is not reachable
diff --git a/packages/opencode-adapter/package.json b/packages/opencode-adapter/package.json
index 9f4bed8..fc82f42 100644
--- a/packages/opencode-adapter/package.json
+++ b/packages/opencode-adapter/package.json
@@ -17,7 +17,7 @@
"typecheck": "tsc -p tsconfig.json"
},
"dependencies": {
- "@opencode-ai/client": "0.0.0-beta-18050"
+ "@opencode-ai/client": "0.0.0-beta-18387"
},
"devDependencies": {
"@opencode2-mobile/test-fixtures": "workspace:*",
diff --git a/packages/opencode-adapter/src/index.test.ts b/packages/opencode-adapter/src/index.test.ts
index 6b95018..4338fcf 100644
--- a/packages/opencode-adapter/src/index.test.ts
+++ b/packages/opencode-adapter/src/index.test.ts
@@ -11,6 +11,7 @@ import {
createOpenCodeClient,
createOpenCodeSession,
createRedirectSafeOpenCodeFetch,
+ findOpenCodeFiles,
getCurrentOpenCodeProject,
getDefaultOpenCodeAgent,
getDefaultOpenCodeLocation,
@@ -24,6 +25,7 @@ import {
interruptOpenCodeSession,
listActiveOpenCodeSessions,
listOpenCodeAgents,
+ listOpenCodeCommands,
listOpenCodeFormRequests,
listOpenCodeMessages,
listOpenCodeModels,
@@ -31,6 +33,7 @@ import {
listOpenCodeProjectSessions,
listOpenCodeSessionInbox,
listOpenCodeSessions,
+ listOpenCodeSkills,
normalizeOpenCodeBaseUrl,
openEventStreamGeneration,
probeEventStream,
@@ -42,6 +45,7 @@ import {
renameOpenCodeSession,
replyOpenCodeForm,
replyOpenCodePermissionRequest,
+ runOpenCodeSessionCommand,
startEventStreamProbe,
steerOpenCodeSessionInboxItem,
switchOpenCodeSessionAgent,
@@ -480,6 +484,155 @@ it("validates and forwards location-scoped agent and model choices", async () =>
);
});
+it("lists location-scoped commands and skills through the generated client", async () => {
+ const api = createFakeOpenCodeApi({
+ commands: [{ description: "Review changes", name: "review" }],
+ location: {
+ directory: "/workspace",
+ project: { canonical: "/workspace", directory: "/workspace", id: "project-1" },
+ workspaceID: "wrk_test",
+ },
+ skills: [
+ {
+ content: "Skill content",
+ id: "release",
+ location: "/workspace/.opencode/skills/release.md",
+ name: "Release",
+ slash: true,
+ },
+ ],
+ });
+ const client = createOpenCodeClient({ baseUrl: "https://fake.invalid", fetch: api.fetch });
+ const location = { directory: "/workspace", workspaceID: "wrk_test" };
+
+ await expect(listOpenCodeCommands(client, location)).resolves.toMatchObject({
+ data: [{ name: "review" }],
+ });
+ await expect(listOpenCodeSkills(client, location)).resolves.toMatchObject({
+ data: [{ id: "release", slash: true }],
+ });
+ expect(api.requests.slice(-2).map((request) => request.query)).toEqual([
+ {
+ "location[directory]": ["/workspace"],
+ "location[workspace]": ["wrk_test"],
+ },
+ {
+ "location[directory]": ["/workspace"],
+ "location[workspace]": ["wrk_test"],
+ },
+ ]);
+
+ await runOpenCodeSessionCommand(client, "ses_test", {
+ command: "review",
+ delivery: "queue",
+ text: "src/index.ts",
+ });
+ expect(api.requests.at(-1)).toMatchObject({
+ jsonBody: {
+ command: "review",
+ delivery: "queue",
+ text: "src/index.ts",
+ },
+ path: "/api/session/ses_test/command",
+ });
+
+ await expect(interruptOpenCodeSession(client, "ses_test", false)).resolves.toEqual({
+ interrupted: true,
+ });
+ expect(api.requests.at(-1)).toMatchObject({
+ path: "/api/session/ses_test/interrupt",
+ query: { continue: ["false"] },
+ });
+});
+
+it("finds bounded files at an exact location", async () => {
+ const api = createFakeOpenCodeApi({
+ files: [{ path: "src/index.ts", type: "file" }],
+ });
+ const client = createOpenCodeClient({ baseUrl: "https://fake.invalid", fetch: api.fetch });
+
+ await expect(
+ findOpenCodeFiles(client, { directory: "/workspace", workspaceID: "wrk_test" }, "index", {
+ limit: 20,
+ }),
+ ).resolves.toMatchObject({ data: [{ path: "src/index.ts" }] });
+ expect(api.requests.at(-1)).toMatchObject({
+ path: "/api/fs/find",
+ query: {
+ limit: ["20"],
+ "location[directory]": ["/workspace"],
+ "location[workspace]": ["wrk_test"],
+ query: ["index"],
+ type: ["file"],
+ },
+ });
+});
+
+it("rejects malformed command and skill catalogs", async () => {
+ const location = {
+ directory: "/workspace",
+ project: { canonical: "/workspace", directory: "/workspace", id: "project-1" },
+ };
+ const client = {
+ command: { list: vi.fn(async () => ({ data: [{ name: "" }], location })) },
+ skill: {
+ list: vi.fn(async () => ({ data: [{ id: "release", name: "Release" }], location })),
+ },
+ } as unknown as ReturnType;
+
+ await expect(listOpenCodeCommands(client, { directory: "/workspace" })).rejects.toThrow(
+ "MALFORMED_COMMAND_LIST",
+ );
+ await expect(listOpenCodeSkills(client, { directory: "/workspace" })).rejects.toThrow(
+ "MALFORMED_SKILL_LIST",
+ );
+});
+
+it("rejects malformed or over-limit file search results", async () => {
+ const location = {
+ directory: "/workspace",
+ project: { canonical: "/workspace", directory: "/workspace", id: "project-1" },
+ };
+ const client = {
+ file: {
+ find: vi
+ .fn()
+ .mockResolvedValueOnce({ data: [{ path: "src" }], location })
+ .mockResolvedValueOnce({
+ data: [
+ { path: "one.ts", type: "file" },
+ { path: "two.ts", type: "file" },
+ ],
+ location,
+ }),
+ },
+ } as unknown as ReturnType;
+
+ await expect(findOpenCodeFiles(client, { directory: "/workspace" }, "src")).rejects.toThrow(
+ "MALFORMED_FILE_FIND",
+ );
+ await expect(
+ findOpenCodeFiles(client, { directory: "/workspace" }, "src", { limit: 1 }),
+ ).rejects.toThrow("MALFORMED_FILE_FIND");
+});
+
+it.each(["../secret.txt", "src/../../secret.txt", "/etc/passwd", "C:/outside.txt", "//host/share"])(
+ "rejects an out-of-location file search path: %s",
+ async (path) => {
+ const location = {
+ directory: "/workspace",
+ project: { canonical: "/workspace", directory: "/workspace", id: "project-1" },
+ };
+ const client = {
+ file: { find: vi.fn(async () => ({ data: [{ path, type: "file" }], location })) },
+ } as unknown as ReturnType;
+
+ await expect(findOpenCodeFiles(client, { directory: "/workspace" }, "file")).rejects.toThrow(
+ "MALFORMED_FILE_FIND",
+ );
+ },
+);
+
it("resolves the highest-priority configured default agent", async () => {
const api = createFakeOpenCodeApi({
configEntries: [
@@ -517,7 +670,7 @@ it("returns null when no config document defines a default agent", async () => {
await expect(getDefaultOpenCodeAgent(client, { directory: "/workspace" })).resolves.toBeNull();
});
-it("forwards composer and execution operations and returns generated inbox values", async () => {
+it("forwards composer and execution operations", async () => {
const item = {
delivery: "queue" as const,
id: "msg_admission",
@@ -529,6 +682,7 @@ it("forwards composer and execution operations and returns generated inbox value
const switchAgent = vi.fn(async () => undefined);
const switchModel = vi.fn(async () => undefined);
const prompt = vi.fn(async () => item);
+ const command = vi.fn(async () => undefined);
const permissionReply = vi.fn(async () => undefined);
const list = vi.fn(async () => [item]);
const projectedMessage = {
@@ -541,13 +695,14 @@ it("forwards composer and execution operations and returns generated inbox value
const cancel = vi.fn(async () => undefined);
const steer = vi.fn(async () => undefined);
const queue = vi.fn(async () => undefined);
- const interrupt = vi.fn(async () => undefined);
+ const interrupt = vi.fn(async () => ({ interrupted: true }));
const background = vi.fn(async () => undefined);
const wait = vi.fn(async () => undefined);
const client = {
permission: { reply: permissionReply },
session: {
background,
+ command,
inbox: { cancel, list, queue, steer },
interrupt,
message,
@@ -570,6 +725,18 @@ it("forwards composer and execution operations and returns generated inbox value
options,
),
).resolves.toBe(item);
+ await expect(
+ runOpenCodeSessionCommand(
+ client,
+ "ses_test",
+ {
+ command: "review",
+ delivery: "queue",
+ text: "src unicode-æ",
+ },
+ options,
+ ),
+ ).resolves.toBeUndefined();
await expect(listOpenCodeSessionInbox(client, "ses_test", options)).resolves.toEqual([item]);
await expect(
getOpenCodeSessionMessage(client, "ses_test", "msg_admission", options),
@@ -577,7 +744,9 @@ it("forwards composer and execution operations and returns generated inbox value
await cancelOpenCodeSessionInboxItem(client, "ses_test", "msg_admission", options);
await steerOpenCodeSessionInboxItem(client, "ses_test", "msg_admission", options);
await queueOpenCodeSessionInboxItem(client, "ses_test", "msg_admission", options);
- await interruptOpenCodeSession(client, "ses_test", true, options);
+ await expect(interruptOpenCodeSession(client, "ses_test", true, options)).resolves.toEqual({
+ interrupted: true,
+ });
await backgroundOpenCodeSession(client, "ses_test", options);
await waitForOpenCodeSession(client, "ses_test", options);
await replyOpenCodePermissionRequest(client, "ses_test", "per_test", "once", options);
@@ -588,6 +757,15 @@ it("forwards composer and execution operations and returns generated inbox value
{ delivery: "queue", id: "msg_admission", sessionID: "ses_test", text: "Hello" },
options,
);
+ expect(command).toHaveBeenCalledWith(
+ {
+ command: "review",
+ delivery: "queue",
+ sessionID: "ses_test",
+ text: "src unicode-æ",
+ },
+ options,
+ );
expect(list).toHaveBeenCalledWith({ sessionID: "ses_test" }, options);
expect(message).toHaveBeenCalledWith(
{ messageID: "msg_admission", sessionID: "ses_test" },
@@ -785,9 +963,24 @@ it("recognizes an unauthorized error wrapped by the generated client", () => {
expect(
classifyOpenCodeError(new Error("transport", { cause: { _tag: "SessionNotFoundError" } })),
).toBe("NOT_FOUND");
+ expect(
+ classifyOpenCodeError(new Error("transport", { cause: { _tag: "ProjectNotFoundError" } })),
+ ).toBe("NOT_FOUND");
expect(
classifyOpenCodeError(new Error("transport", { cause: { _tag: "FormNotFoundError" } })),
).toBe("NOT_FOUND");
+ expect(
+ classifyOpenCodeError(new Error("transport", { cause: { _tag: "CommandNotFoundError" } })),
+ ).toBe("NOT_FOUND");
+ expect(
+ classifyOpenCodeError(new Error("transport", { cause: { _tag: "SkillNotFoundError" } })),
+ ).toBe("NOT_FOUND");
+ expect(
+ classifyOpenCodeError(new Error("transport", { cause: { _tag: "CommandEvaluationError" } })),
+ ).toBe("INVALID_REQUEST");
+ expect(
+ classifyOpenCodeError(new Error("transport", { cause: { _tag: "CommandExecutionError" } })),
+ ).toBe("INVALID_REQUEST");
expect(
classifyOpenCodeError(new Error("transport", { cause: { _tag: "MessageNotFoundError" } })),
).toBe("MESSAGE_NOT_FOUND");
diff --git a/packages/opencode-adapter/src/index.ts b/packages/opencode-adapter/src/index.ts
index 180c83a..3f93302 100644
--- a/packages/opencode-adapter/src/index.ts
+++ b/packages/opencode-adapter/src/index.ts
@@ -1,5 +1,7 @@
import {
+ type CommandInfo,
type FileDiffInfo,
+ type FileSystemEntry,
type FormAnswer,
type FormState,
type LocationGetOutput,
@@ -15,9 +17,10 @@ import {
type SessionMessageInfo,
type SessionMessagesResponse,
type SessionsResponse,
+ type SkillInfo,
} from "@opencode-ai/client";
-export const openCodeClientContractVersion = "0.0.0-beta-18050";
+export const openCodeClientContractVersion = "0.0.0-beta-18387";
export type OpenCodeClientOptions = {
authorization?: string;
@@ -150,6 +153,13 @@ export type OpenCodeSessionPromptOptions = Omit<
delivery: SessionInboxDelivery;
id: string;
};
+type GeneratedSessionCommandInput = Parameters[0];
+export type OpenCodeSessionCommandOptions = Omit<
+ GeneratedSessionCommandInput,
+ "command" | "sessionID"
+> & {
+ command: string;
+};
export async function getDefaultOpenCodeLocation(
client: OpenCodeClient,
@@ -231,6 +241,19 @@ export async function listOpenCodeAgents(
return output;
}
+export async function listOpenCodeCommands(
+ client: OpenCodeClient,
+ location: LocationRef,
+ options?: OpenCodeRequestOptions,
+) {
+ const output = await client.command.list({ location: locationInput(location) }, options);
+ validateResolvedLocation(output.location);
+ if (!Array.isArray(output.data) || !output.data.every(isValidCommand)) {
+ throw new Error("MALFORMED_COMMAND_LIST");
+ }
+ return output;
+}
+
export async function getDefaultOpenCodeAgent(
client: OpenCodeClient,
location: LocationRef,
@@ -271,6 +294,41 @@ export async function listOpenCodeModels(
return output;
}
+export async function listOpenCodeSkills(
+ client: OpenCodeClient,
+ location: LocationRef,
+ options?: OpenCodeRequestOptions,
+) {
+ const output = await client.skill.list({ location: locationInput(location) }, options);
+ validateResolvedLocation(output.location);
+ if (!Array.isArray(output.data) || !output.data.every(isValidSkill)) {
+ throw new Error("MALFORMED_SKILL_LIST");
+ }
+ return output;
+}
+
+export async function findOpenCodeFiles(
+ client: OpenCodeClient,
+ location: LocationRef,
+ query: string,
+ options?: OpenCodeRequestOptions & { limit?: number },
+) {
+ const limit = Math.min(Math.max(options?.limit ?? 20, 1), 100);
+ const output = await client.file.find(
+ { limit, location: locationInput(location), query, type: "file" },
+ options?.signal ? { signal: options.signal } : undefined,
+ );
+ validateResolvedLocation(output.location);
+ if (
+ !Array.isArray(output.data) ||
+ output.data.length > limit ||
+ !output.data.every((entry) => isValidFileSystemEntry(entry) && entry.type === "file")
+ ) {
+ throw new Error("MALFORMED_FILE_FIND");
+ }
+ return output;
+}
+
export async function getDefaultOpenCodeModel(
client: OpenCodeClient,
location: LocationRef,
@@ -444,6 +502,15 @@ export async function promptOpenCodeSession(
return inbox;
}
+export async function runOpenCodeSessionCommand(
+ client: OpenCodeClient,
+ sessionID: string,
+ input: OpenCodeSessionCommandOptions,
+ options?: OpenCodeRequestOptions,
+) {
+ await client.session.command({ ...input, sessionID }, options);
+}
+
export async function listOpenCodeSessionInbox(
client: OpenCodeClient,
sessionID: string,
@@ -925,6 +992,37 @@ function isValidAgent(agent: unknown) {
);
}
+function isValidCommand(command: unknown): command is CommandInfo {
+ return (
+ isRecord(command) &&
+ typeof command.name === "string" &&
+ Boolean(command.name) &&
+ isOptionalString(command.description) &&
+ Object.keys(command).every((key) => key === "name" || key === "description")
+ );
+}
+
+function isValidFileSystemEntry(entry: unknown): entry is FileSystemEntry {
+ return (
+ isRecord(entry) &&
+ typeof entry.path === "string" &&
+ isSafeRelativeFilePath(entry.path) &&
+ (entry.type === "file" || entry.type === "directory")
+ );
+}
+
+function isSafeRelativeFilePath(path: string) {
+ const normalized = path.replaceAll("\\", "/");
+ return (
+ Boolean(normalized) &&
+ !normalized.startsWith("/") &&
+ !/^[A-Za-z]:\//.test(normalized) &&
+ normalized
+ .split("/")
+ .every((segment) => Boolean(segment) && segment !== "." && segment !== "..")
+ );
+}
+
function isValidModel(model: unknown) {
return (
isRecord(model) &&
@@ -945,6 +1043,21 @@ function isValidModel(model: unknown) {
);
}
+function isValidSkill(skill: unknown): skill is SkillInfo {
+ return (
+ isRecord(skill) &&
+ typeof skill.id === "string" &&
+ Boolean(skill.id) &&
+ typeof skill.name === "string" &&
+ Boolean(skill.name) &&
+ isOptionalString(skill.description) &&
+ (skill.slash === undefined || typeof skill.slash === "boolean") &&
+ (skill.autoinvoke === undefined || typeof skill.autoinvoke === "boolean") &&
+ typeof skill.location === "string" &&
+ typeof skill.content === "string"
+ );
+}
+
function isValidSessionInboxInfo(value: unknown): value is SessionInboxInfo {
if (
!isRecord(value) ||
@@ -1164,7 +1277,7 @@ function assertSessionAndFormIds(sessionID: string, formID: string) {
}
function isValidFormOwner(value: unknown) {
- // Beta 18050 temporarily uses "global" for MCP elicitations without a session owner.
+ // MCP elicitations can use "global" when no session owns the form.
return typeof value === "string" && (value === "global" || /^ses/.test(value));
}
@@ -1227,9 +1340,21 @@ export function classifyOpenCodeError(error: unknown) {
) {
return "INVALID_REQUEST" as const;
}
- if (hasTagInCause(error, "SessionNotFoundError") || hasTagInCause(error, "FormNotFoundError")) {
+ if (
+ hasTagInCause(error, "SessionNotFoundError") ||
+ hasTagInCause(error, "ProjectNotFoundError") ||
+ hasTagInCause(error, "FormNotFoundError") ||
+ hasTagInCause(error, "CommandNotFoundError") ||
+ hasTagInCause(error, "SkillNotFoundError")
+ ) {
return "NOT_FOUND" as const;
}
+ if (
+ hasTagInCause(error, "CommandEvaluationError") ||
+ hasTagInCause(error, "CommandExecutionError")
+ ) {
+ return "INVALID_REQUEST" as const;
+ }
if (hasTagInCause(error, "MessageNotFoundError")) return "MESSAGE_NOT_FOUND" as const;
if (hasAbortCause(error)) return "TIMEOUT" as const;
if (hasTlsFailure(error)) return "TLS" as const;
@@ -1694,7 +1819,9 @@ export type OpenCodeClient = ReturnType;
export type {
AgentInfo,
AgentListOutput,
+ CommandInfo,
FileDiffInfo,
+ FileSystemEntry,
FormAnswer,
FormField,
FormInfo,
@@ -1720,5 +1847,6 @@ export type {
SessionMessageInfo,
SessionMessagesResponse,
SessionsResponse,
+ SkillInfo,
} from "@opencode-ai/client";
export type { OpenCodeEvent };
diff --git a/packages/opencode-notification-plugin/package.json b/packages/opencode-notification-plugin/package.json
index 4118255..b308f9a 100644
--- a/packages/opencode-notification-plugin/package.json
+++ b/packages/opencode-notification-plugin/package.json
@@ -21,7 +21,7 @@
"typecheck": "tsc -p tsconfig.json"
},
"dependencies": {
- "@opencode-ai/plugin": "0.0.0-beta-18050",
+ "@opencode-ai/plugin": "0.0.0-beta-18387",
"@opencode2-mobile/notification-protocol": "workspace:*"
},
"devDependencies": {
diff --git a/packages/test-fixtures/src/index.ts b/packages/test-fixtures/src/index.ts
index bce94b3..32104d3 100644
--- a/packages/test-fixtures/src/index.ts
+++ b/packages/test-fixtures/src/index.ts
@@ -1,8 +1,10 @@
export type FakeOpenCodeApiOptions = {
agents?: unknown[];
+ commands?: unknown[];
configEntries?: unknown[];
eventFrame?: string;
failures?: Record;
+ files?: unknown[];
forms?: unknown[];
location?: {
directory: string;
@@ -16,6 +18,7 @@ export type FakeOpenCodeApiOptions = {
permissions?: unknown[];
projects?: unknown[];
sessions?: FakeSession[];
+ skills?: unknown[];
vcs?: unknown;
vcsDiff?: unknown[];
};
@@ -111,18 +114,27 @@ export function createFakeOpenCodeApi(options: FakeOpenCodeApiOptions = {}) {
if (url.pathname === "/api/agent") {
return json({ location: resolvedLocation(options, url), data: options.agents ?? [] });
}
+ if (url.pathname === "/api/command") {
+ return json({ location: resolvedLocation(options, url), data: options.commands ?? [] });
+ }
if (url.pathname === "/api/config") {
return json(options.configEntries ?? []);
}
if (url.pathname === "/api/model") {
return json({ location: resolvedLocation(options, url), data: options.models ?? [] });
}
+ if (url.pathname === "/api/skill") {
+ return json({ location: resolvedLocation(options, url), data: options.skills ?? [] });
+ }
if (url.pathname === "/api/permission/request") {
return json({ location: resolvedLocation(options, url), data: options.permissions ?? [] });
}
if (url.pathname === "/api/form/request") {
return json({ location: resolvedLocation(options, url), data: pendingForms });
}
+ if (url.pathname === "/api/fs/find") {
+ return json({ location: resolvedLocation(options, url), data: options.files ?? [] });
+ }
const formMatch = url.pathname.match(
/^\/api\/session\/([^/]+)\/form\/([^/]+)\/(state|reply|cancel)$/,
);
@@ -253,6 +265,14 @@ export function createFakeOpenCodeApi(options: FakeOpenCodeApiOptions = {}) {
data,
});
}
+ const commandMatch = url.pathname.match(/^\/api\/session\/([^/]+)\/command$/);
+ if (commandMatch && method === "POST") {
+ return new Response(null, { status: 204 });
+ }
+ const interruptMatch = url.pathname.match(/^\/api\/session\/([^/]+)\/interrupt$/);
+ if (interruptMatch && method === "POST") {
+ return json({ interrupted: true });
+ }
const sessionMatch = url.pathname.match(/^\/api\/session\/([^/]+)$/);
if (sessionMatch && method === "GET") {
const session = sessions.find((candidate) => candidate.id === sessionMatch[1]);
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 5210420..8c39b50 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -197,8 +197,8 @@ importers:
packages/opencode-adapter:
dependencies:
'@opencode-ai/client':
- specifier: 0.0.0-beta-18050
- version: 0.0.0-beta-18050(effect@4.0.0-rc.111)
+ specifier: 0.0.0-beta-18387
+ version: 0.0.0-beta-18387(effect@4.0.0-rc.111)
devDependencies:
'@opencode2-mobile/test-fixtures':
specifier: workspace:*
@@ -213,8 +213,8 @@ importers:
packages/opencode-notification-plugin:
dependencies:
'@opencode-ai/plugin':
- specifier: 0.0.0-beta-18050
- version: 0.0.0-beta-18050(supports-color@8.1.1)
+ specifier: 0.0.0-beta-18387
+ version: 0.0.0-beta-18387(supports-color@8.1.1)
'@opencode2-mobile/notification-protocol':
specifier: workspace:*
version: link:../notification-protocol
@@ -1324,11 +1324,11 @@ packages:
resolution: {integrity: sha512-wH5EHOmLi0rEazphPbecAzmjd12I6/Yv/SiHdkA9LSycsQk7RuuTp7am5/o62qYr0RScE7Pc9icXGBbsr6cesA==}
engines: {node: ^14.21.3 || >=16}
- '@opencode-ai/ai@0.0.0-beta-18050':
- resolution: {integrity: sha512-PkUeZ1eN5Z4a5lWUqsD+nBmVcsLiz5gxnG83Vicqug13U6WBC/nV80is696/QfL0C/JYFVPQ4lTeq0q5+pSK3w==}
+ '@opencode-ai/ai@0.0.0-beta-18387':
+ resolution: {integrity: sha512-EVwwZWppdj/a702ub7weQz7TLH3U7CZV6pMFW2iAyRu5/Vu8nt8jKCK+1d1th7awZcbhsowj9c/ZzLlD09nPaA==}
- '@opencode-ai/client@0.0.0-beta-18050':
- resolution: {integrity: sha512-zWZv5X23iyx+/mxwiAi18YY/VMjQofTaH7RyKMBt7KL6FmaKAWf9Q05zbQhrLX8DYJD3MOtbjBeQCGLsPUDU8g==}
+ '@opencode-ai/client@0.0.0-beta-18387':
+ resolution: {integrity: sha512-vr+PS4A06MLtnzG1rLY9azAOkaA2EdvsCtvGYU8DnJPQEPHQaPAUFSbAxcx9ngZW9cAImFJ52P3NDgWyA0RJHA==}
peerDependencies:
effect: 4.0.0-rc.111
solid-js: '>=1.9.0'
@@ -1338,12 +1338,12 @@ packages:
solid-js:
optional: true
- '@opencode-ai/plugin@0.0.0-beta-18050':
- resolution: {integrity: sha512-Y5xOXdhlNSFf+AfzE0wVKcwY7jFWRS/nlhR4MOSaeFwaN34k9ZBx19jUpetNwSTMxz5K1YzJJWsLrA1NjdHe+A==}
+ '@opencode-ai/plugin@0.0.0-beta-18387':
+ resolution: {integrity: sha512-ChIMWSMzQd5Cfy7bwhj4QAM8w5sBN126yWmMNc6rYsGk4MQVgx/8ERoOD7EhUIiey3qy8qpqHXtiCVTqZsw0sg==}
peerDependencies:
- '@opencode-ai/theme': 0.0.0-beta-18050
- '@opentui/core': '>=0.5.7'
- '@opentui/solid': '>=0.5.7'
+ '@opencode-ai/theme': 0.0.0-beta-18387
+ '@opentui/core': '>=0.5.8'
+ '@opentui/solid': '>=0.5.8'
solid-js: '>=1.9.0'
peerDependenciesMeta:
'@opencode-ai/theme':
@@ -1355,11 +1355,11 @@ packages:
solid-js:
optional: true
- '@opencode-ai/protocol@0.0.0-beta-18050':
- resolution: {integrity: sha512-HDQMnvGp8IU0MdBRbEuydX1WQm09BZ4HJm9iSMQwzweJuQ2HNscgzHJPIH6P02BsbbtfJ8J7sZGPItrz1tWSgw==}
+ '@opencode-ai/protocol@0.0.0-beta-18387':
+ resolution: {integrity: sha512-8L5qRSTesAp3SMEMxIw0ahGwO1K+RirdCtWkw1wIO2UiRzhjJi4P1M/25ZvEJ8Z88zrwcxuYFMN3cwfsaCoKpw==}
- '@opencode-ai/schema@0.0.0-beta-18050':
- resolution: {integrity: sha512-/D6VXaWlytTXR3IOiMLIKuPcfp7FQNUzRPm9z3K7UBFd1Bw4q/WZksaf5RVcBGz+0YRxYMc1V4D7MFlceSgtyg==}
+ '@opencode-ai/schema@0.0.0-beta-18387':
+ resolution: {integrity: sha512-of3chc9a4eQ24z9gTct10kBlhycZ+cI3JeIl51ideHVySY5aQLbGBo5SL7NLiuC7pu2kUY5OdmLjPDnLXAskzg==}
'@oxc-project/types@0.143.0':
resolution: {integrity: sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA==}
@@ -6345,9 +6345,9 @@ snapshots:
'@noble/ciphers@1.0.0': {}
- '@opencode-ai/ai@0.0.0-beta-18050(supports-color@8.1.1)':
+ '@opencode-ai/ai@0.0.0-beta-18387(supports-color@8.1.1)':
dependencies:
- '@opencode-ai/schema': 0.0.0-beta-18050
+ '@opencode-ai/schema': 0.0.0-beta-18387
'@smithy/eventstream-codec': 4.2.14
'@smithy/util-utf8': 4.2.2
aws4fetch: 1.0.20
@@ -6356,32 +6356,32 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@opencode-ai/client@0.0.0-beta-18050(effect@4.0.0-rc.111)':
+ '@opencode-ai/client@0.0.0-beta-18387(effect@4.0.0-rc.111)':
dependencies:
- '@opencode-ai/protocol': 0.0.0-beta-18050
- '@opencode-ai/schema': 0.0.0-beta-18050
+ '@opencode-ai/protocol': 0.0.0-beta-18387
+ '@opencode-ai/schema': 0.0.0-beta-18387
optionalDependencies:
effect: 4.0.0-rc.111
- '@opencode-ai/plugin@0.0.0-beta-18050(supports-color@8.1.1)':
+ '@opencode-ai/plugin@0.0.0-beta-18387(supports-color@8.1.1)':
dependencies:
'@ai-sdk/provider': 3.0.8
- '@opencode-ai/ai': 0.0.0-beta-18050(supports-color@8.1.1)
- '@opencode-ai/client': 0.0.0-beta-18050(effect@4.0.0-rc.111)
- '@opencode-ai/protocol': 0.0.0-beta-18050
- '@opencode-ai/schema': 0.0.0-beta-18050
+ '@opencode-ai/ai': 0.0.0-beta-18387(supports-color@8.1.1)
+ '@opencode-ai/client': 0.0.0-beta-18387(effect@4.0.0-rc.111)
+ '@opencode-ai/protocol': 0.0.0-beta-18387
+ '@opencode-ai/schema': 0.0.0-beta-18387
'@standard-schema/spec': 1.1.0
effect: 4.0.0-rc.111
zod: 4.1.8
transitivePeerDependencies:
- supports-color
- '@opencode-ai/protocol@0.0.0-beta-18050':
+ '@opencode-ai/protocol@0.0.0-beta-18387':
dependencies:
- '@opencode-ai/schema': 0.0.0-beta-18050
+ '@opencode-ai/schema': 0.0.0-beta-18387
effect: 4.0.0-rc.111
- '@opencode-ai/schema@0.0.0-beta-18050':
+ '@opencode-ai/schema@0.0.0-beta-18387':
dependencies:
'@standard-schema/spec': 1.1.0
effect: 4.0.0-rc.111
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index dfc185b..15b402e 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -24,8 +24,10 @@ minimumReleaseAgeExclude:
- '@biomejs/cli-linux-x64@2.5.8'
- '@biomejs/cli-win32-arm64@2.5.8'
- '@biomejs/cli-win32-x64@2.5.8'
- - '@opencode-ai/client@0.0.0-beta-18050'
- - '@opencode-ai/protocol@0.0.0-beta-18050'
- - '@opencode-ai/schema@0.0.0-beta-18050'
+ - '@opencode-ai/client@0.0.0-beta-18387'
+ - '@opencode-ai/ai@0.0.0-beta-18387'
+ - '@opencode-ai/plugin@0.0.0-beta-18387'
+ - '@opencode-ai/protocol@0.0.0-beta-18387'
+ - '@opencode-ai/schema@0.0.0-beta-18387'
- '@tanstack/query-core@5.102.2'
- '@tanstack/react-query@5.102.2'