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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/remote-session-slash-commands.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kilocode/cli": minor
---

Remote CLI: expose slash command discovery, execution, and `/new` session creation over the relay. `list_commands` and `send_command` (including the built-in `compact` flow) are scoped to the current session's directory. `create_session` creates a root session in that directory, attaches it to the relay heartbeat set, and returns the new session id only after the heartbeat completes so the mobile client can navigate immediately. Failures are sanitized; the command is not auto-retried and the user may retry manually after a transient relay failure.
81 changes: 58 additions & 23 deletions packages/opencode/src/cli/cmd/tui/thread.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,20 +24,52 @@ import {
sanitizedProcessEnv,
} from "@opencode-ai/core/util/opencode-process"
import { validateSession } from "./validate-session"
import { createParentRemoteExitBridge, type RemoteExitBridgeClient } from "@/kilocode/cli/cmd/tui/remote-exit-bridge" // kilocode_change
import type { Exit } from "./context/exit" // kilocode_change

declare global {
const KILO_WORKER_PATH: string
}

type RpcClient = ReturnType<typeof Rpc.client<typeof rpc>>

export function embeddedRemoteExitClient<T>(external: boolean, client: T | undefined): T | undefined {
return external ? undefined : client
}

export async function runEmbeddedRemoteExitBridge(input: {
client: RemoteExitBridgeClient
exit: Exit
done: Promise<unknown>
timeoutMs?: number
}) {
const timeoutMs = input.timeoutMs ?? 5_000
const bridge = createParentRemoteExitBridge(input.client, input.exit)
let ready = false
try {
try {
await withTimeout(bridge.ready(), timeoutMs, "remote exit startup timed out")
ready = true
} catch {
await bridge.dispose(timeoutMs).catch(() => {})
}
await input.done
} finally {
if (ready) await bridge.dispose(timeoutMs).catch(() => {})
}
}

// kilocode_change start - lazy-load the TUI app after daemon attach in source mode
async function start(input: StartInput) {
async function start(input: StartInput, remoteExitClient?: RpcClient) {
const app = await import("./app")
// start() creates the renderer for both paths, daemon/worker
const renderer = await app.createTuiRenderer(input.config)
const handle = app.tui({ ...input, renderer })
await handle.done
if (!remoteExitClient) {
await handle.done
return
}
await runEmbeddedRemoteExitBridge({ client: remoteExitClient, exit: handle.exit, done: handle.done }) // kilocode_change
}
// kilocode_change end

Expand Down Expand Up @@ -360,28 +392,31 @@ export const TuiThreadCommand = cmd({
}

// kilocode_change start
await start({
// kilocode_change - shared lazy loader also supports daemon attach
url: transport.url,
async onSnapshot() {
const tui = writeHeapSnapshot("tui.heapsnapshot")
const server = await client.call("snapshot", undefined)
return [tui, server]
},
config,
directory: cwd,
fetch: transport.fetch,
headers: transport.headers, // kilocode_change
events: transport.events,
args: {
continue: args.continue,
sessionID: args.session,
agent: args.agent,
model: args.model,
prompt,
fork: args.fork,
await start(
{
// kilocode_change - shared lazy loader also supports daemon attach
url: transport.url,
async onSnapshot() {
const tui = writeHeapSnapshot("tui.heapsnapshot")
const server = await client.call("snapshot", undefined)
return [tui, server]
},
config,
directory: cwd,
fetch: transport.fetch,
headers: transport.headers, // kilocode_change
events: transport.events,
args: {
continue: args.continue,
sessionID: args.session,
agent: args.agent,
model: args.model,
prompt,
fork: args.fork,
},
},
})
embeddedRemoteExitClient(external, client),
)
// kilocode_change end
} finally {
await stop()
Expand Down
12 changes: 12 additions & 0 deletions packages/opencode/src/cli/cmd/tui/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { AppRuntime } from "@/effect/app-runtime"
import { ensureProcessMetadata } from "@opencode-ai/core/util/opencode-process"
import { Effect } from "effect"
import { disposeAllInstancesAndEmitGlobalDisposed } from "@/server/global-lifecycle"
import { createWorkerRemoteExit } from "@/kilocode/cli/cmd/tui/remote-exit-worker" // kilocode_change

ensureProcessMetadata("worker")

Expand Down Expand Up @@ -45,8 +46,17 @@ GlobalBus.on("event", (event) => {
})

let server: Awaited<ReturnType<typeof Server.listen>> | undefined
const remoteExit = createWorkerRemoteExit(Rpc.emit) // kilocode_change

export const rpc = {
// kilocode_change start - worker lifecycle hooks for remote exit
tuiReady() {
remoteExit.ready()
},
tuiGone() {
remoteExit.gone()
},
// kilocode_change end
async fetch(input: { url: string; method: string; headers: Record<string, string>; body?: string }) {
const headers = { ...input.headers }
const auth = ServerAuth.header()
Expand Down Expand Up @@ -89,7 +99,9 @@ export const rpc = {
)
},
async shutdown() {
// kilocode_change
Log.Default.info("worker shutting down")
remoteExit.shutdown() // kilocode_change

await InstanceRuntime.disposeAllInstances()
if (server) await server.stop(true)
Expand Down
194 changes: 194 additions & 0 deletions packages/opencode/src/kilo-sessions/attached-state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
// kilocode_change - extracted state machine that separates presence-owned
// attached session ids from newly-created (pending) session announcements.
// `setPresence` (driven by the presence service) is authoritative for the
// presence set and adopts any pending ids it now covers. `announce` (driven
// by the create_session command) is duplicate-safe across both sets and
// resolves coherently with presence semantics on heartbeat failure so a
// presence-adopted id is never reported as an attach failure.
//
// Concurrency invariant: `lastSentKey` is the union the relay last observed
// through a successfully completed heartbeat. It is updated:
// - synchronously by `setPresence` (fire-and-forget, but the union we
// record is the current union which includes any in-flight pending ids,
// so a later `setPresence` with the same union correctly skips)
// - by `announce` only on a successful awaited heartbeat AND only when
// the announce still belongs to the current lifecycle (the captured
// `myGeneration` matches the current `generation`)
// It is NEVER updated on:
// - the synchronous prefix of `announce` (would let a concurrent
// `setPresence` skip its heartbeat because the cache falsely claims the
// relay is up to date, leaving the relay desynced if the announce then
// fails)
// - the failure branch of `announce` (the relay never saw the new union,
// and a concurrent setPresence may have already advanced lastSentKey to
// a newer state via its own fire-and-forget heartbeat)
// - a stale success after `reset()` (the relay's observed state belongs
// to the old lifecycle; the new lifecycle's setPresence calls must
// drive the new baseline, not a stale overwrite)

export namespace AttachedState {
export type Options = {
/** Fires the relay heartbeat. May be fire-and-forget or awaited. Must
* reject (not resolve) when no relay connection is available so that
* `announce` cannot silently mark a session as attached. */
heartbeat: () => Promise<void>
log?: { warn: (msg: string, meta?: unknown) => void }
}

export type Interface = {
/** Replace the presence-owned set. Adopts any pending ids now covered by
* presence and fires a heartbeat if and only if the current union
* diverges from `lastSentKey`. The fire-and-forget heartbeat's union
* is recorded into `lastSentKey` synchronously (the current union
* already includes any in-flight pending ids). */
setPresence(ids: readonly string[]): void
/** Awaitable duplicate-safe announcement. No-ops when the id is already
* present in either set. On heartbeat failure rolls back only its own
* pending entry, does NOT touch `lastSentKey`, and re-throws — unless
* presence adopted the id while the heartbeat was in flight, in which
* case presence is authoritative and the attach resolves successfully.
* On success advances `lastSentKey` to the current union. */
announce(id: string): Promise<void>
/** Current union of presence ∪ pending for the next heartbeat payload. */
union(): ReadonlySet<string>
/** Clear both sets across a connection lifecycle. The next setPresence
* call after reset will fire a heartbeat because the baseline key is
* empty. */
reset(): void
}

// kilocode_change - collision-safe union key. The historical "|" join
// was ambiguous: {"a", "b"} and {"a|b"} both encoded to "a|b" so two
// distinct id sets could collide. Length-prefixing each id makes the
// encoding unambiguous for any string id: knowing the prefix length
// tells the decoder exactly how many characters of id follow, so no
// delimiter can ever escape its enclosing id.
function keyOf(ids: Iterable<string>): string {
const sorted = [...ids].sort()
const parts: string[] = []
for (const id of sorted) parts.push(`${id.length}:${id}`)
return parts.join(",")
}

export function create(options: Options): Interface {
const presence = new Set<string>()
const pending = new Set<string>()
// kilocode_change - in-flight dedup. Concurrent announce(id) callers
// share the same Promise so they observe one consistent outcome and
// the heartbeat fires at most once per id. The owner is the caller
// that installed the Promise; only it manages the map entry. On settle
// the owner clears the entry if the map still points to its Promise
// (a later announce may have replaced it). Joiners only await.
const inflight = new Map<string, Promise<void>>()
// kilocode_change end
let lastSentKey = ""
// kilocode_change - lifecycle generation. Incremented on reset() so a
// late success from an in-flight announce started before the reset
// cannot overwrite the new lifecycle's lastSentKey. The old completion
// would otherwise write keyOf(union()) using the new generation's union,
// causing redundant/stale change detection in subsequent setPresence calls.
let generation = 0

function union(): Set<string> {
const out = new Set(presence)
for (const id of pending) out.add(id)
return out
}

function fireHeartbeat() {
void options
.heartbeat()
.catch((err) => options.log?.warn("attached-state heartbeat failed", { error: String(err) }))
}

return {
setPresence(ids) {
const next = new Set(ids)
presence.clear()
for (const id of next) presence.add(id)
// Adopt any pending ids that presence now covers so the relay does
// not receive redundant heartbeat updates for ids it already knows.
for (const id of [...pending]) {
if (presence.has(id)) pending.delete(id)
}
const key = keyOf(union())
if (key === lastSentKey) return
// Record the union synchronously so a subsequent setPresence with
// the same union is a no-op. The union already includes any
// in-flight pending ids, so a concurrent announce cannot poison it.
lastSentKey = key
fireHeartbeat()
},

async announce(id) {
if (presence.has(id)) return
// kilocode_change - join an in-flight Promise for this id instead
// of starting a second heartbeat.
const existing = inflight.get(id)
if (existing) {
await existing
return
}
if (pending.has(id)) {
// A previous announce already resolved and is awaiting presence
// adoption. No further work to do.
return
}
// kilocode_change - capture the lifecycle generation so a late
// success after reset() cannot overwrite the new lifecycle's
// lastSentKey with keyOf(union()) computed from the new state.
const myGeneration = generation
const owned = (async () => {
pending.add(id)
try {
await options.heartbeat()
} catch (err) {
// Roll back only the entry this call added. If presence adopted
// the id while the heartbeat was in flight, presence is the
// authoritative owner and the attach succeeded from the
// caller's perspective — resolve cleanly and leave the union
// alone. Otherwise the id is truly unattached: drop the pending
// entry and surface the failure. lastSentKey is never touched
// here because the relay never observed the new union and a
// concurrent setPresence may have already advanced it.
if (presence.has(id)) return
pending.delete(id)
throw err
}
// Success: the relay now has the union that includes this id.
// Advance lastSentKey so the next setPresence with the same union
// is a no-op. The id stays in `pending` until presence adopts it;
// this keeps the union stable across presence churn. If a
// reset() happened while the heartbeat was in flight, the
// captured generation no longer matches and we must NOT write
// lastSentKey — the new lifecycle owns the baseline now.
if (myGeneration !== generation) return
lastSentKey = keyOf(union())
})()
inflight.set(id, owned)
try {
await owned
} finally {
// Only clear if the map still points to OUR promise; a later
// announce may have replaced it and we must not clobber that.
if (inflight.get(id) === owned) inflight.delete(id)
}
},

union() {
return union()
},

reset() {
presence.clear()
pending.clear()
inflight.clear()
lastSentKey = ""
// kilocode_change - bump the lifecycle generation so any in-flight
// announce started before this reset will skip its lastSentKey
// write on success (its captured generation no longer matches).
generation += 1
},
}
}
}
Loading
Loading