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/mobile-instance-metadata.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kilo-code": minor
---

Advertise optional instance kind, process start time, and current Git branch in CLI heartbeats.
2 changes: 1 addition & 1 deletion packages/opencode/src/cli/cmd/remote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export const RemoteCommand = cmd({
// advertised for the explicit `kilo remote` command path.
// enableRemote() also ensures a default advertisement; this explicit call
// remains a legitimate replace (or no-op when identical) per the contract.
KiloSessions.setInstanceAdvertisement(buildInstanceAdvertisement(Instance.directory))
KiloSessions.setInstanceAdvertisement(buildInstanceAdvertisement(Instance.directory, "remote"))

await KiloSessions.enableRemote()
console.log("Remote connection enabled.")
Expand Down
15 changes: 12 additions & 3 deletions packages/opencode/src/kilo-sessions/instance-advertisement.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,30 @@
// kilocode_change - new file
// Shared derivation for the spawn-capable instance advertisement payload.
// Used by both `kilo remote` (explicit CLI) and `enableRemote()` (covers `/remote`
// and KILO_REMOTE / remote_control auto-enable) so all enable paths advertise
// identically.
// and KILO_REMOTE / remote_control auto-enable) so all enable paths share
// the same process identity.
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import os from "node:os"
import path from "node:path"
import { performance } from "node:perf_hooks"
import type { RemoteProtocol } from "@/kilo-sessions/remote-protocol"

// Use process startup, not the first advertisement or a later reconnect.
const started = new Date(performance.timeOrigin).toISOString()

function truncate(value: string, max: number) {
return value.length > max ? value.slice(0, max) : value
}

export function buildInstanceAdvertisement(directory: string): RemoteProtocol.InstanceAdvertisement {
export function buildInstanceAdvertisement(
directory: string,
kind: RemoteProtocol.InstanceAdvertisement["kind"] = "cli",
): RemoteProtocol.InstanceAdvertisement {
return {
name: truncate(os.hostname(), 64),
projectName: truncate(path.basename(directory) || directory, 64),
version: truncate(InstallationVersion, 32),
kind,
startedAt: started,
}
}
6 changes: 5 additions & 1 deletion packages/opencode/src/kilo-sessions/kilo-sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -771,7 +771,11 @@ export namespace KiloSessions {
for (const row of sessions) await syncPrLinkTriple(row.id, pr.triple)
}
const advertised = pr.prLink ? sessions.map((row) => ({ ...row, prLink: pr.prLink })) : sessions
const instance = instanceAdvertisement
const instance = instanceAdvertisement && {
...instanceAdvertisement,
// Reuse the current session branch without splitting a surrogate pair.
gitBranch: gitBranch?.slice(0, 24).replace(/[\uD800-\uDBFF]$/, ""),
}
return { type: "heartbeat", sessions: advertised, ...(instance ? { instance } : {}) }
}

Expand Down
6 changes: 6 additions & 0 deletions packages/opencode/src/kilo-sessions/remote-protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ export namespace RemoteProtocol {
name: z.string().min(1).max(64), // os.hostname(), truncated
projectName: z.string().min(1).max(64), // basename(Instance.directory), truncated
version: z.string().max(32).optional(), // InstallationVersion, truncated
// Older CLIs advertise only name, projectName, and optional version.
// Keep metadata optional until those CLI versions and retained relay
// attachments are confirmed retired.
kind: z.enum(["cli", "remote"]).optional(),
startedAt: z.iso.datetime({ precision: 3 }).length(24).optional(),
gitBranch: z.string().max(24).optional(),
})
export type InstanceAdvertisement = z.infer<typeof InstanceAdvertisement>

Expand Down
25 changes: 19 additions & 6 deletions packages/opencode/test/kilocode/cli/cmd/remote.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,8 @@
// kilocode_change - new file
// K1 W1: verify `buildInstanceAdvertisement`'s payload shape as real behavior.
//
// The `RemoteCommand` handler itself is a CLI entry point that calls
// `bootstrap(process.cwd(), async () => { ... })` and then awaits an abort
// signal that never resolves in a test — it cannot be driven end-to-end.
// `buildInstanceAdvertisement` is extracted from the handler specifically so
// the advertised payload is independently testable as real behavior, not via
// a source-text/regex assertion on the handler's structure.
// The command handler's enablement path is covered in kilo-sessions.test.ts.
// These tests exercise the shared builder without the CLI lifecycle.

import { describe, expect, test } from "bun:test"
// Shared helper lives in kilo-sessions; remote.ts re-exports for the CLI path.
Expand All @@ -21,6 +17,23 @@ describe("RemoteCommand instance advertisement (K1 W1)", () => {
expect(typeof advertisement.version).toBe("string")
})

test("keeps process identity across builder calls, directories, and kinds", async () => {
const first = buildInstanceAdvertisement("/projects/first")
// Advance past the timestamp resolution, not an asynchronous readiness boundary.
await Bun.sleep(2)
const second = buildInstanceAdvertisement("/projects/second", "remote")
expect(first.kind).toBe("cli")
expect(first.projectName).toBe("first")
expect(second.kind).toBe("remote")
expect(second.projectName).toBe("second")
expect(second.startedAt).toBe(first.startedAt)
expect(second.name).toBe(first.name)
expect(first.startedAt).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/)
const start = Date.now() - process.uptime() * 1000
expect(Date.parse(first.startedAt!)).toBeGreaterThanOrEqual(start - 100)
expect(Date.parse(first.startedAt!)).toBeLessThanOrEqual(start + 100)
})

test("buildInstanceAdvertisement truncates an overlong project directory name to 64 chars", () => {
const longName = "a".repeat(100)
const advertisement = buildInstanceAdvertisement(`/Users/igor/projects/${longName}`)
Expand Down
155 changes: 135 additions & 20 deletions packages/opencode/test/kilocode/kilo-sessions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,10 +292,12 @@ multi.live("isolates the process-wide listener by instance directory", () => {
describe("KiloSessions.setInstanceAdvertisement (K1 W1 / DEF-1)", () => {
let heartbeatCalls = 0
let outOfBand: Promise<void> | undefined
let snapshot: RemoteProtocol.InstanceAdvertisement | undefined

beforeEach(() => {
heartbeatCalls = 0
outOfBand = undefined
snapshot = undefined
process.env["KILO_DISABLE_SESSION_INGEST"] = "0"
delete process.env["KILO_SESSION_INGEST_URL"]
process.env["KILO_API_KEY"] = "tok"
Expand All @@ -316,7 +318,9 @@ describe("KiloSessions.setInstanceAdvertisement (K1 W1 / DEF-1)", () => {
send() {},
heartbeat: () => {
heartbeatCalls += 1
const p = options.getSessions().then(() => undefined)
const p = options.getSessions().then((payload) => {
snapshot = payload.instance
})
outOfBand = p
return p
},
Expand Down Expand Up @@ -367,14 +371,16 @@ describe("KiloSessions.setInstanceAdvertisement (K1 W1 / DEF-1)", () => {
reset("tok")
})

// Reads the `getSessions` closure that kilo-sessions.ts passed to
// RemoteWS.connect when enableRemote() ran. The mock stores calls
// on the spy's `.mock.calls` array; we extract the Options object.
function capturedGetSessions(): () => Promise<RemoteProtocol.Heartbeat> {
// Read the latest connection so reconnect tests exercise the new closure.
function captured() {
const calls = (RemoteWS.connect as unknown as { mock: { calls: { 0: RemoteWS.Options }[] } }).mock.calls
const getSessions = calls[0]?.[0].getSessions
if (!getSessions) throw new Error("RemoteWS.connect was not called")
return getSessions as () => Promise<RemoteProtocol.Heartbeat>
const options = calls.at(-1)?.[0]
if (!options) throw new Error("RemoteWS.connect was not called")
return options
}

function capturedGetSessions() {
return captured().getSessions as () => Promise<RemoteProtocol.Heartbeat>
}

test("enableRemote alone advertises the instance (covers /remote and auto-enable)", async () => {
Expand All @@ -390,6 +396,34 @@ describe("KiloSessions.setInstanceAdvertisement (K1 W1 / DEF-1)", () => {
expect(payload.instance).toBeDefined()
expect(payload.instance!.projectName.length).toBeGreaterThan(0)
expect(payload.instance!.name.length).toBeGreaterThan(0)
expect(payload.instance?.kind).toBe("cli")
expect(payload.instance?.startedAt).toBeDefined()
expect(payload.instance?.gitBranch).toBeDefined()
},
})
})

test("explicit remote command advertises remote before enablement", async () => {
await using tmp = await tmpdir({ git: true })
await provide({
directory: tmp.path,
fn: async () => {
const bootstrap = await import("../../src/cli/bootstrap")
const { RemoteCommand } = await import("../../src/cli/cmd/remote")
spyOn(bootstrap, "bootstrap").mockImplementation(async (_directory, cb) => cb())
const enable = KiloSessions.enableRemote
const stop = new Error("stop before the command waits for shutdown")
spyOn(KiloSessions, "enableRemote").mockImplementation(async () => {
await enable()
throw stop
})
const handler = RemoteCommand.handler
if (typeof handler !== "function") throw new Error("remote command handler is missing")
const result = await Promise.resolve(handler({ _: [], $0: "kilo" })).catch((err: unknown) => err)
expect(result).toBe(stop)
const payload = await capturedGetSessions()()
expect(payload.instance?.kind).toBe("remote")
expect(payload.instance?.startedAt).toBeDefined()
},
})
})
Expand Down Expand Up @@ -421,15 +455,19 @@ describe("KiloSessions.setInstanceAdvertisement (K1 W1 / DEF-1)", () => {
fn: async () => {
await KiloSessions.enableRemote()
// Explicit set keeps replace semantics even when enableRemote already
// derived a default advertisement.
// derived a default advertisement. Do not invent metadata for a legacy ad.
KiloSessions.setInstanceAdvertisement({
name: "mbp-igor",
projectName: "cloud",
version: "1.2.3",
})
const payload = await capturedGetSessions()()
expect(payload.type).toBe("heartbeat")
expect(payload.instance).toEqual({ name: "mbp-igor", projectName: "cloud", version: "1.2.3" })
await outOfBand
expect(snapshot).toEqual({
name: "mbp-igor",
projectName: "cloud",
version: "1.2.3",
gitBranch: expect.any(String),
})
},
})
})
Expand All @@ -446,8 +484,7 @@ describe("KiloSessions.setInstanceAdvertisement (K1 W1 / DEF-1)", () => {
KiloSessions.setInstanceAdvertisement({ name: "h", projectName: "p" })
await outOfBand
expect(heartbeatCalls).toBe(beforeHeartbeatCalls + 1)
const afterPayload = await capturedGetSessions()()
expect(afterPayload.instance).toEqual({ name: "h", projectName: "p" })
expect(snapshot).toEqual({ name: "h", projectName: "p", gitBranch: expect.any(String) })
},
})
})
Expand All @@ -464,22 +501,29 @@ describe("KiloSessions.setInstanceAdvertisement (K1 W1 / DEF-1)", () => {
KiloSessions.setInstanceAdvertisement({ name: "second", projectName: "p" })
await outOfBand
expect(heartbeatCalls).toBe(before + 1)
const payload = await capturedGetSessions()()
expect(payload.instance).toEqual({ name: "second", projectName: "p" })
expect(snapshot).toEqual({ name: "second", projectName: "p", gitBranch: expect.any(String) })
},
})
})

test("explicit set before enableRemote is preserved (no re-set on enable)", async () => {
test("explicit metadata before enableRemote is preserved except for the current branch", async () => {
await using tmp = await tmpdir({ git: true })
await provide({
directory: tmp.path,
fn: async () => {
// Contract: set before connect → flag stored; enable must not replace.
KiloSessions.setInstanceAdvertisement({ name: "pre-set", projectName: "proj", version: "9.9.9" })
const instance = {
name: "pre-set",
projectName: "proj",
version: "9.9.9",
kind: "remote" as const,
startedAt: "2020-01-02T03:04:05.678Z",
gitBranch: "stale",
}
KiloSessions.setInstanceAdvertisement(instance)
await KiloSessions.enableRemote()
const payload = await capturedGetSessions()()
expect(payload.instance).toEqual({ name: "pre-set", projectName: "proj", version: "9.9.9" })
expect(payload.instance?.gitBranch).not.toBe("stale")
expect(payload.instance).toEqual({ ...instance, gitBranch: expect.any(String) })
},
})
})
Expand All @@ -502,6 +546,77 @@ describe("KiloSessions.setInstanceAdvertisement (K1 W1 / DEF-1)", () => {
})
})

test("reconnect heartbeat refreshes the branch without replacing process identity", async () => {
await using tmp = await tmpdir({ git: true })
await provide({
directory: tmp.path,
fn: async () => {
await KiloSessions.enableRemote()
const first = await capturedGetSessions()()
if (!first.instance) throw new Error("initial heartbeat is missing its instance advertisement")
const { AppRuntime } = await import("../../src/effect/app-runtime")
const { Vcs } = await import("../../src/project/vcs")
const vcs = await AppRuntime.runPromise(Vcs.Service.use((svc) => Effect.succeed(svc)))
spyOn(vcs, "branch").mockReturnValue(Effect.succeed("feature/reconnected"))
captured().onDisconnect?.()
captured().onOpen?.()
await outOfBand
expect(snapshot).toEqual({ ...first.instance, gitBranch: "feature/reconnected" })
},
})
})

test("refreshes and bounds only instance branches while preserving process identity", async () => {
await using tmp = await tmpdir({ git: true })
await provide({
directory: tmp.path,
fn: async () => {
const { AppRuntime } = await import("../../src/effect/app-runtime")
const { Vcs } = await import("../../src/project/vcs")
const vcs = await AppRuntime.runPromise(Vcs.Service.use((svc) => Effect.succeed(svc)))
const branch = spyOn(vcs, "branch").mockReturnValue(Effect.succeed("main"))
await KiloSessions.enableRemote()
const first = await capturedGetSessions()()
if (!first.instance) throw new Error("initial heartbeat is missing its instance advertisement")
const chat = await AppRuntime.runPromise(Session.Service.use((svc) => svc.create({})))
KiloSessions.setAttachedSessions([chat.id])
for (const [input, expected] of [
["feature/current", "feature/current"],
["a".repeat(25), "a".repeat(24)],
['"\\\n\u0001'.repeat(7), '"\\\n\u0001'.repeat(6)],
["界".repeat(25), "界".repeat(24)],
["\u{10400}".repeat(13), "\u{10400}".repeat(12)],
["a".repeat(23) + "\u{10400}", "a".repeat(23)],
["a".repeat(22) + "\u{10400}b", "a".repeat(22) + "\u{10400}"],
["", ""],
[undefined, undefined],
]) {
branch.mockReturnValue(Effect.succeed(input))
const payload = await capturedGetSessions()()
expect(payload.instance).toEqual({ ...first.instance, gitBranch: expected })
expect(payload.sessions.find((row) => row.id === chat.id)).toMatchObject({ id: chat.id, gitBranch: input })
}
branch.mockReturnValue(Effect.die(new Error("branch unavailable")))
const payload = await capturedGetSessions()()
expect(payload.instance).toEqual({ ...first.instance, gitBranch: undefined })
},
})
})

test("omits the whole instance when no advertisement is present", async () => {
await using tmp = await tmpdir({ git: true })
await provide({
directory: tmp.path,
fn: async () => {
await KiloSessions.enableRemote()
KiloSessions.resetInstanceAdvertisementForTests()
const payload = await capturedGetSessions()()
expect(payload).not.toHaveProperty("instance")
expect(payload.sessions).toEqual([])
},
})
})

test("per-session platform resolution matches meta() order — env var fallback", async () => {
// The getSessions closure's platform field is computed as:
// KiloSession.resolvePlatform(id) || process.env["KILO_PLATFORM"] || "cli"
Expand Down
Loading
Loading