Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
e4f8196
feat(opencode): remote create_session fields, rename adoption, title …
iscekic Jul 30, 2026
cd250b1
test(opencode): prove cancel→reprompt reaches idle; lock exit survivor
iscekic Jul 30, 2026
58b4528
test(opencode): drop AppRuntime spy from create_session default test
iscekic Jul 30, 2026
9e0b201
fix(opencode): bound rename marks, wire title report path, harden tit…
iscekic Jul 30, 2026
8388857
fix(kilo-sessions): mark bookkeeping before ingest sync, AppRuntime, …
iscekic Jul 30, 2026
b6299cb
Merge remote-tracking branch 'origin/main' into remote-cli-lifecycle-…
iscekic Jul 31, 2026
9743b4e
fix(kilo-sessions): keep title report self-healing if ingest.sync fails
iscekic Jul 31, 2026
a7834fe
fix(kilo-sessions): optimistic knownTitles with full title-path rollback
iscekic Jul 31, 2026
843690b
style(kilo-sessions): prettier title Updated handler
iscekic Jul 31, 2026
a3b5964
fix(kilo-sessions): preserve newer title state
iscekic Jul 31, 2026
172a8ea
refactor(kilo-sessions): simplify title reporting tests
iscekic Jul 31, 2026
b6e913b
Merge remote-tracking branch 'origin/main' into remote-cli-lifecycle-…
iscekic Jul 31, 2026
f108af2
fix(kilo-sessions): report unseeded title updates
iscekic Jul 31, 2026
de91b69
fix(kilo-sessions): consume unseeded title marks
iscekic Jul 31, 2026
14e9907
test(kilo-sessions): cover unseeded title marks
iscekic Jul 31, 2026
3b0a65f
test(kilo-sessions): unique ids for unseeded title tests
iscekic Jul 31, 2026
dccd694
test(prompt-queue): harden cancel test against interrupt flake
iscekic Jul 31, 2026
c3f785b
test(prompt-queue): wait for both queued follow-ups before cancel
iscekic Jul 31, 2026
284d7e8
test(config-overlay): drop tight 15s timeout on permission refresh
iscekic Jul 31, 2026
94a04fd
test(httpapi-session): raise deletion-race timeout for windows CI
iscekic Jul 31, 2026
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-cli-lifecycle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kilocode/cli": minor
---

Remote CLI session lifecycle: create_session accepts optional agent, model, and orgId (org claim rides session metadata); CLI adopts backend renames via system session.renamed and POSTs local title changes (generation-aware) to the ingest title route so auto-titles and explicit renames stay in sync.
207 changes: 197 additions & 10 deletions packages/opencode/src/kilo-sessions/kilo-sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@ import { RemoteSender } from "@/kilo-sessions/remote-sender"
import { RemoteProtocol } from "@/kilo-sessions/remote-protocol"
import { buildInstanceAdvertisement } from "@/kilo-sessions/instance-advertisement"
import { AttachedState } from "@/kilo-sessions/attached-state"
import {
clear as clearRenameMarks,
consumeAutoTitle,
consumeRenameAdoption,
markAutoTitle,
markRenameAdopted,
} from "@/kilo-sessions/rename-adoptions"
import { SessionStatus } from "@/session/status"
import { Telemetry } from "@kilocode/kilo-telemetry"
import { Question } from "@/question"
Expand Down Expand Up @@ -60,6 +67,11 @@ export namespace KiloSessions {
sessionID: string,
input: { id: string; message: string },
) => Effect.Effect<{ ok: true } | { ok: false; reason: string }, never>
readonly reportSessionTitle: (
Comment thread
iscekic marked this conversation as resolved.
sessionID: string,
title: string,
opts: { generated: boolean },
) => Effect.Effect<{ ok: true } | { ok: false; reason: string }, never>
}

export class Service extends Context.Service<Service, Interface>()("@kilocode/KiloSessions") {}
Expand All @@ -81,6 +93,18 @@ export namespace KiloSessions {

const ttlMs = 10_000

/**
* Classify an `http_<status>` reason as a permanent (non-retryable) failure.
* 4xx client errors are permanent except 408 (Request Timeout) and 429
* (Too Many Requests), which are transient and should be retried.
*/
function isPermanentHttpStatus(reason: string): boolean {
const match = reason.match(/^http_(\d+)$/)
if (!match) return false
const status = parseInt(match[1], 10)
return status >= 400 && status < 500 && status !== 408 && status !== 429
Comment thread
iscekic marked this conversation as resolved.
}

function agentNotificationTimeoutMs(): number {
const value = process.env["KILO_AGENT_NOTIFICATION_TIMEOUT_MS"]
return value ? Number(value) : 10_000
Expand Down Expand Up @@ -315,6 +339,15 @@ export namespace KiloSessions {
Effect.gen(function* () {
const config = yield* Config.Service
const sessions = yield* Session.Service

const reportSessionTitle = Effect.fn("KiloSessions.reportSessionTitle")(function* (
sessionID: string,
title: string,
opts: { generated: boolean },
) {
return yield* Effect.promise(() => reportTitleChange(sessionID, title, opts.generated))
})

const state = yield* InstanceState.make(
Effect.fn("KiloSessions.state")(function* (ctx) {
if (ingestDisabled) return
Expand All @@ -332,18 +365,101 @@ export namespace KiloSessions {
handlers.set(def.type, fn)
}

// Last-known title per session so we only POST on actual title changes.
// Seed on Created and from existing rows at bootstrap so the first real
// rename (rename-before-prompt, or first rename after process restart)
// is not treated as a seed-only sighting and dropped (Decision 8).
const knownTitles = new Map<string, string>()
yield* sessions.list().pipe(
Effect.map((list) => {
for (const s of list) knownTitles.set(s.id, s.title)
}),
Effect.orElseSucceed(() => undefined),
)
watch(Session.Event.Created, (evt) => {
const sessionID = evt.properties.info.id
const info = evt.properties.info
const sessionID = info.id
if (typeof info.title === "string") knownTitles.set(sessionID, info.title)
return create(sessionID).catch((error) => log.error("share init create failed", { sessionID, error }))
})
watch(Session.Event.Updated, async (evt) => {
const sessionID = evt.properties.sessionID
const session = await Effect.runPromise(sessions.get(sessionID).pipe(Effect.orElseSucceed(() => null)))
if (!session) return
await ingest.sync(sessionID, [
{ type: "kilo_meta", data: await meta(sessionID) },
{ type: "session", data: transport(session) },
])
// Consume marks before the network hop so the 60s TTL does not span
// token resolution + ingest.sync. Advance knownTitles optimistically
// so a concurrent Updated sees sameTitle (no duplicate POST with a
// wrong generated flag). On ingest or title-POST failure restore
// prev + consumed marks so the next Updated re-derives and retries.
const prev = knownTitles.get(sessionID)
const sameTitle = prev === session.title
// Same-title Updated (setTitle no-op / double session.renamed): still
// consume a matching rename adoption after sync so the mark cannot
// stick and swallow a later real local rename (Decision 8).
const outcome = (():
| { kind: "same" }
| { kind: "seed" }
| { kind: "adopted" }
| { kind: "report"; generated: boolean } => {
if (sameTitle) return { kind: "same" }
if (prev === undefined) return { kind: "seed" }
if (consumeRenameAdoption(sessionID, session.title)) return { kind: "adopted" }
Comment thread
iscekic marked this conversation as resolved.
return { kind: "report", generated: consumeAutoTitle(sessionID, session.title) }
Comment thread
iscekic marked this conversation as resolved.
})()
const restoreTitleState = () => {
Comment thread
iscekic marked this conversation as resolved.
// Only restore if this handler still owns the knownTitles slot.
// A concurrent handler may have advanced it to a newer title; in
// that case do not clobber it with this handler's stale prev.
if (knownTitles.get(sessionID) === session.title) {
if (prev === undefined) knownTitles.delete(sessionID)
else knownTitles.set(sessionID, prev)
}
if (outcome.kind === "adopted") markRenameAdopted(sessionID, session.title)
else if (outcome.kind === "report" && outcome.generated) markAutoTitle(sessionID, session.title)
}
knownTitles.set(sessionID, session.title)
try {
await ingest.sync(sessionID, [
{ type: "kilo_meta", data: await meta(sessionID, session) },
{ type: "session", data: transport(session) },
])
} catch (error) {
restoreTitleState()
log.error("session updated ingest failed", { sessionID, error })
return
}
if (outcome.kind === "same") consumeRenameAdoption(sessionID, session.title)
if (outcome.kind !== "report") return
// Production path goes through the Interface method (not private helper).
const { AppRuntime } = await import("@/effect/app-runtime")
const reported = await AppRuntime.runPromise(
reportSessionTitle(sessionID, session.title, { generated: outcome.generated }),
)
if (!reported.ok) {
Comment thread
iscekic marked this conversation as resolved.
// Permanent failures (non-retryable 4xx client errors) mean the
// server rejected this title definitively; keep the new title so
// the next same-title Updated is a no-op instead of retrying
// forever. Transient failures (5xx, 408, 429, network errors,
// not_connected) still restore + retry.
const isPermanent = isPermanentHttpStatus(reported.reason)
if (isPermanent) {
Comment thread
iscekic marked this conversation as resolved.
log.warn("session title report permanent failure; title preserved", {
sessionID,
reason: reported.reason,
})
} else {
restoreTitleState()
log.warn("session title report failed; will retry on next Updated", {
sessionID,
reason: reported.reason,
})
}
}
})
watch(Session.Event.Deleted, (evt) => {
const sessionID = evt.properties.sessionID
knownTitles.delete(sessionID)
clearRenameMarks(sessionID)
})
watch(MessageV2.Event.Updated, async (evt) => {
await ingest.sync(evt.properties.info.sessionID, [{ type: "message", data: evt.properties.info }])
Expand Down Expand Up @@ -471,7 +587,7 @@ export namespace KiloSessions {
)
})

return Service.of({ init, sendAgentNotification })
return Service.of({ init, sendAgentNotification, reportSessionTitle })
}),
)

Expand All @@ -487,6 +603,7 @@ export namespace KiloSessions {
export const testLayer = Layer.succeed(Service, {
init: () => Effect.void,
sendAgentNotification: () => Effect.succeed({ ok: false, reason: "not_connected" } as const),
reportSessionTitle: () => Effect.succeed({ ok: false, reason: "not_connected" } as const),
})

export const node = LayerNode.suspend(() => LayerNode.make(layer, [Bus.node, Config.node, Session.node]))
Expand Down Expand Up @@ -1031,6 +1148,51 @@ export namespace KiloSessions {
}
}

async function reportTitleChange(
sessionID: string,
title: string,
generated: boolean,
): Promise<{ ok: true } | { ok: false; reason: string }> {
if (ingestDisabled) {
return { ok: false, reason: "not_connected" }
}
const readiness = await withTimeout(
resolveReadiness(sessionID),
agentNotificationTimeoutMs(),
"session title readiness timed out",
).catch(() => ({ ok: false, reason: "not_connected" }) as const)
if (!readiness.ok) {
log.warn("report session title skipped", { sessionID, reason: readiness.reason })
return readiness
}
return postSessionTitle(sessionID, readiness.client, title, generated)
}

async function postSessionTitle(
sessionID: string,
client: Client,
title: string,
generated: boolean,
): Promise<{ ok: true } | { ok: false; reason: string }> {
try {
const response = await client.fetch(`${client.url}/api/session/${encodeURIComponent(sessionID)}/title`, {
method: "POST",
body: JSON.stringify({ title, generated }),
})
if (response.ok) {
log.info("session title reported", { sessionID, generated })
return { ok: true }
}
const reason = `http_${response.status}`
log.error("session title report failed", { sessionID, status: response.status })
return { ok: false, reason }
} catch (error) {
const reason = error instanceof Error ? error.message : String(error)
log.error("session title report failed", { sessionID, error: reason })
return { ok: false, reason }
}
}

export async function remove(sessionId: string) {
const client = await getClient()
if (!client) return
Expand Down Expand Up @@ -1090,7 +1252,7 @@ export namespace KiloSessions {
await ingest.sync(sessionId, [
{
type: "kilo_meta",
data: await meta(sessionId),
data: await meta(sessionId, session),
},
{
type: "session",
Expand Down Expand Up @@ -1160,10 +1322,10 @@ export namespace KiloSessions {
return AppRuntime.runPromise(Vcs.Service.use((svc) => svc.branch()))
}

async function meta(sessionId?: string) {
async function meta(sessionId?: string, info?: Session.Info | null) {
Comment thread
iscekic marked this conversation as resolved.
const override = sessionId ? KiloSession.resolvePlatform(sessionId) : undefined
const platform = override || process.env["KILO_PLATFORM"] || "cli"
const orgId = await getOrgId()
const orgId = await getOrgId(sessionId, info)
const gitBranch = await branch().catch(() => undefined)
const gitUrl = await getGitUrl().catch(() => undefined)

Expand All @@ -1175,7 +1337,16 @@ export namespace KiloSessions {
}
}

async function getOrgId(): Promise<Uuid | undefined> {
/** Test seam: meta() without preloaded info (Session.get failure → env/auth fallback). */
export async function _metaForTests(sessionId?: string, info?: Session.Info | null) {
Comment thread
iscekic marked this conversation as resolved.
return meta(sessionId, info)
}

async function getOrgId(sessionId?: string, info?: Session.Info | null): Promise<Uuid | undefined> {
// Per-session org from metadata (remote create_session) wins over process-global env/auth.
const fromMeta = await resolveSessionOrg(sessionId, info)
if (fromMeta) return fromMeta

const env = process.env["KILO_ORG_ID"]
if (isUuid(env)) return env

Expand All @@ -1186,6 +1357,22 @@ export namespace KiloSessions {
})
}

async function resolveSessionOrg(sessionId?: string, info?: Session.Info | null): Promise<Uuid | undefined> {
if (!sessionId) return undefined
const resolved =
info !== undefined
? info
: await (async () => {
const { AppRuntime } = await import("@/effect/app-runtime")
return AppRuntime.runPromise(Session.Service.use((svc) => svc.get(SessionID.make(sessionId)))).catch(
() => null,
)
})()
if (!resolved) return undefined
const raw = resolved.metadata?.orgId
return typeof raw === "string" && isUuid(raw) ? raw : undefined
}

function isUuid(value: string | undefined): value is Uuid {
if (!value) return false
return Uuid.safeParse(value).success
Expand Down
Loading
Loading