Skip to content
Closed
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
55 changes: 55 additions & 0 deletions packages/opencode/src/kilo-sessions/kilo-sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { Vcs } from "@/project/vcs"
import simpleGit from "simple-git"
import type { RemoteWS } from "@/kilo-sessions/remote-ws"
import type { RemoteSender } from "@/kilo-sessions/remote-sender"
import { RemoteProtocol } from "@/kilo-sessions/remote-protocol"
import { AttachedState } from "@/kilo-sessions/attached-state"
import { SessionStatus } from "@/session/status"
import { Telemetry } from "@kilocode/kilo-telemetry"
Expand All @@ -34,6 +35,56 @@ import { withTimeout } from "@/util/timeout"
import { Snapshot } from "@/snapshot"
import { cumulativeSessionDiff } from "@/kilocode/session-portability/cumulative-diff"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import os from "os"

/** Strip controls, collapse whitespace, clamp. Empty input may yield "". */
function cleanLabel(value: string, max: number): string {
return value
.replace(/[\u0000-\u001f\u007f]/g, " ")
.replace(/\s+/g, " ")
.trim()
.slice(0, max)
}

/** Strip controls, collapse whitespace, clamp, fall back when empty. */
export function sanitizeLabel(value: string, fallback: string, max: number): string {
const cleaned = cleanLabel(value, max)
return cleaned.length > 0 ? cleaned : fallback
}

/** Basename of a launch directory for the heartbeat `projectName` field. */
export function projectLabel(directory: string): string {
// Strip trailing slashes so `/tmp/proj/` → "proj" (not "").
const trimmed = directory.replace(/[\\/]+$/, "")
const base = trimmed.split(/[\\/]/).pop() ?? ""
return sanitizeLabel(base, "unknown-project", 64)
}

/** OS hostname for the heartbeat `name` field; empty → "Kilo runtime". */
export function hostLabel(hostname = os.hostname()): string {
return sanitizeLabel(hostname, "Kilo runtime", 64)
}

/** CLI version clamped to the cloud contract's 32-char cap. */
export function versionLabel(version = InstallationVersion): string | undefined {
Comment thread
iscekic marked this conversation as resolved.
const cleaned = cleanLabel(version, 32)
return cleaned.length > 0 ? cleaned : undefined
}

/** Build and validate the spawner `instance` once at remote enable. */
export function buildRemoteInstance(input: {
directory: string
hostname?: string
version?: string
}): RemoteProtocol.Instance {
const version = versionLabel(input.version)
return RemoteProtocol.Instance.parse({
name: hostLabel(input.hostname),
projectName: projectLabel(input.directory),
...(version ? { version } : {}),
})
}

async function provide<R>(input: { directory: string; fn: () => R }): Promise<R> {
const { provide } = await import("@/kilocode/instance")
Expand Down Expand Up @@ -480,6 +531,9 @@ export namespace KiloSessions {
// Capture directory so the heartbeat timer can re-enter the Instance context
// (setInterval runs outside AsyncLocalStorage scope)
const directory = Instance.directory
// Spawner identity for the mobile Run-on picker. Validated once here so a
// bad value fails at enable rather than silently per heartbeat.
const instance = buildRemoteInstance({ directory })
const getSessions = async () => {
const [gitUrl, gitBranch] = await Promise.all([
getGitUrl().catch(() => undefined),
Expand Down Expand Up @@ -520,6 +574,7 @@ export namespace KiloSessions {
getToken: kilocodeToken,
withContext: (fn) => provide({ directory, fn }),
getSessions,
instance,
log,
onOpen: () => {
void Bus.publish(Instance.current, Event.RemoteStatusChanged, { enabled: true, connected: true })
Expand Down
11 changes: 11 additions & 0 deletions packages/opencode/src/kilo-sessions/remote-protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,22 @@ export namespace RemoteProtocol {
attachments: z.boolean().optional(),
})
.optional()

// Identity of the `kilo remote` spawner for the mobile Run-on picker.
// Absent on legacy CLIs that predate the spawner; the relay excludes those.
export const Instance = z.object({
name: z.string().min(1).max(64),
projectName: z.string().min(1).max(64),
version: z.string().max(32).optional(),
})
export type Instance = z.infer<typeof Instance>

export const Heartbeat = z.object({
type: z.literal("heartbeat"),
sessions: z.array(SessionInfo),
protocolVersion: z.string().optional(), // lets relay detect CLI capabilities without probing commands
capabilities: Capabilities,
instance: Instance.optional(),
})
export type Heartbeat = z.infer<typeof Heartbeat>

Expand Down
28 changes: 24 additions & 4 deletions packages/opencode/src/kilo-sessions/remote-sender.ts
Original file line number Diff line number Diff line change
Expand Up @@ -683,22 +683,42 @@ export namespace RemoteSender {
return
}
if (msg.command === "create_session") {
// kilocode_change start - remote /new creation: root session, attached + heartbeat before response
// kilocode_change start - remote /new creation: root session, attached + heartbeat before response.
// Scope selection is three explicit cases:
// (1) sessionId ABSENT -> connection-scoped (options.directory / launch dir)
// (2) sessionId present and decodable -> session-scoped (that session's directory)
// (3) sessionId present but UNDECODABLE -> reject (same as parse-gate error)
// A present-but-undecodable sessionId must NOT fall through to (1).
const parsed = CreateSessionRequest.safeParse(msg.data)
const current = msg.sessionId ? decodeSessionID(msg.sessionId) : Option.none<SessionID>()
if (!parsed.success || Option.isNone(current)) {
if (!parsed.success) {
options.conn.send({
type: "response",
id: msg.id,
error: "invalid create_session command",
})
return
}
// Distinguish absent vs present-but-undecodable: only omit sessionId for (1).
let directory: Promise<string> | string
if (msg.sessionId === undefined) {
directory = options.directory
} else {
const current = decodeSessionID(msg.sessionId)
if (Option.isNone(current)) {
options.conn.send({
type: "response",
id: msg.id,
error: "invalid create_session command",
})
return
}
directory = session.get(current.value).then((info) => info.directory)
}
const run = options.provide ?? provide
void (async () => {
try {
const result = await run({
directory: (await session.get(current.value)).directory,
directory: await directory,
fn: async () => {
const created = await sessionCreate({})
// attachSession is the duplicate-safe seam: it mutates the
Expand Down
4 changes: 4 additions & 0 deletions packages/opencode/src/kilo-sessions/remote-ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ export namespace RemoteWS {
gatherTimeout?: number
/** Max unresolved gather operations before cycles send degraded heartbeats. Defaults to 4. */
maxOutstandingGathers?: number
/** Spawner identity advertised on every heartbeat. Omitted on legacy callers. */
instance?: RemoteProtocol.Instance
}

export type Connection = {
Expand Down Expand Up @@ -238,6 +240,7 @@ export namespace RemoteWS {
protocolVersion: InstallationVersion,
capabilities: { attachments: true },
sessions: fresh,
...(options.instance ? { instance: options.instance } : {}),
})
if (sentLive) {
// A waiter requiring a specific id is satisfied only when
Expand Down Expand Up @@ -274,6 +277,7 @@ export namespace RemoteWS {
protocolVersion: InstallationVersion,
capabilities: { attachments: true },
sessions: lastGood ?? [],
...(options.instance ? { instance: options.instance } : {}),
})
waiters = cycleWaiters.concat(waiters)
}
Expand Down
81 changes: 81 additions & 0 deletions packages/opencode/test/kilocode/sessions/remote-instance.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { describe, expect, test } from "bun:test"
import {
buildRemoteInstance,
hostLabel,
projectLabel,
sanitizeLabel,
versionLabel,
} from "../../../src/kilo-sessions/kilo-sessions"

describe("remote instance labels", () => {
test("hostLabel uses hostname and falls back when empty", () => {
expect(hostLabel("macbook.local")).toBe("macbook.local")
expect(hostLabel(" ")).toBe("Kilo runtime")
expect(hostLabel("")).toBe("Kilo runtime")
})

test("hostLabel strips controls, collapses whitespace, clamps to 64", () => {
expect(hostLabel("ab\u0001c\td")).toBe("ab c d")
expect(hostLabel("x".repeat(80))).toBe("x".repeat(64))
expect(hostLabel("\u0001\u0002")).toBe("Kilo runtime")
})

test("projectLabel takes basename and falls back", () => {
expect(projectLabel("/Users/igor/Projects/cloud")).toBe("cloud")
expect(projectLabel("/Users/igor/Projects/cloud/")).toBe("cloud")
expect(projectLabel("C:\\work\\app\\")).toBe("app")
expect(projectLabel("/")).toBe("unknown-project")
expect(projectLabel("")).toBe("unknown-project")
})

test("projectLabel sanitizes and clamps", () => {
expect(projectLabel(`/tmp/${"p".repeat(80)}`)).toBe("p".repeat(64))
expect(projectLabel("/tmp/my\nproj")).toBe("my proj")
})

test("versionLabel clamps to 32 and drops empty", () => {
expect(versionLabel("7.4.15")).toBe("7.4.15")
expect(versionLabel("v".repeat(40))).toBe("v".repeat(32))
expect(versionLabel(" ")).toBeUndefined()
expect(versionLabel("\u0000")).toBeUndefined()
})

test("sanitizeLabel applies fallback after empty sanitize", () => {
expect(sanitizeLabel("ok", "fb", 64)).toBe("ok")
expect(sanitizeLabel("\t\n", "fb", 64)).toBe("fb")
})

test("buildRemoteInstance validates a complete instance", () => {
const instance = buildRemoteInstance({
directory: "/tmp/my-app",
hostname: "dev-box",
version: "1.2.3",
})
expect(instance).toEqual({
name: "dev-box",
projectName: "my-app",
version: "1.2.3",
})
})

test("buildRemoteInstance applies hostname and project fallbacks", () => {
const instance = buildRemoteInstance({
directory: "/",
hostname: " ",
version: "9.0.0",
})
expect(instance.name).toBe("Kilo runtime")
expect(instance.projectName).toBe("unknown-project")
expect(instance.version).toBe("9.0.0")
})

test("buildRemoteInstance omits empty version after clamp", () => {
const instance = buildRemoteInstance({
directory: "/tmp/p",
hostname: "h",
version: " ",
})
expect(instance).toEqual({ name: "h", projectName: "p" })
expect(instance).not.toHaveProperty("version")
})
})
54 changes: 54 additions & 0 deletions packages/opencode/test/kilocode/sessions/remote-protocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -321,4 +321,58 @@ describe("RemoteProtocol", () => {
expect(result.data.type).toBe("heartbeat")
}
})

test("heartbeat with instance parses", () => {
const result = RemoteProtocol.Heartbeat.safeParse({
type: "heartbeat",
sessions: [],
instance: { name: "macbook", projectName: "cloud", version: "7.4.15" },
})
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.instance).toEqual({
name: "macbook",
projectName: "cloud",
version: "7.4.15",
})
}
})

test("heartbeat without instance parses", () => {
const result = RemoteProtocol.Heartbeat.safeParse({
type: "heartbeat",
sessions: [],
})
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.instance).toBeUndefined()
}
})

test("heartbeat rejects empty instance.name", () => {
const result = RemoteProtocol.Heartbeat.safeParse({
type: "heartbeat",
sessions: [],
instance: { name: "", projectName: "cloud" },
})
expect(result.success).toBe(false)
})

test("heartbeat rejects overlong instance.projectName", () => {
const result = RemoteProtocol.Heartbeat.safeParse({
type: "heartbeat",
sessions: [],
instance: { name: "host", projectName: "x".repeat(65) },
})
expect(result.success).toBe(false)
})

test("instance rejects overlong version", () => {
const result = RemoteProtocol.Instance.safeParse({
name: "host",
projectName: "proj",
version: "v".repeat(33),
})
expect(result.success).toBe(false)
})
})
Loading
Loading