Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
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
13 changes: 13 additions & 0 deletions packages/core/src/flag/flag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,19 @@ export const Flag = {
OPENCODE_WORKSPACE_ID: process.env["OPENCODE_WORKSPACE_ID"],
OPENCODE_EXPERIMENTAL_WORKSPACES: enabledByExperimental("OPENCODE_EXPERIMENTAL_WORKSPACES"),

// altimate_change start — pilot flag for the Workspaces feature (post-scan prompt +
// altimate link subcommand). Read as a getter so tests and the runtime `--` middleware
// can flip it between plugin activation and command execution.
//
// Opt-in only — deliberately does NOT inherit ``OPENCODE_EXPERIMENTAL`` (as
// ``enabledByExperimental`` would). The pilot ships behind its own explicit
// gate so users already opted into other experimental features don't get
// this one turned on for them. (Kilo cycle 6.)
get ALTIMATE_WORKSPACE() {
return truthy("ALTIMATE_WORKSPACE")
},
// altimate_change end

// Evaluated at access time (not module load) because tests, the CLI, and
// external tooling set these env vars at runtime.
get OPENCODE_DISABLE_PROJECT_CONFIG() {
Expand Down
114 changes: 114 additions & 0 deletions packages/opencode/src/altimate/plugin/onboarding-telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,112 @@
// session loop.
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
import * as OnboardingTelemetry from "../telemetry/onboarding"
// altimate_change start — AI-8398 workspaces trigger. Reaches into the same
// EventV2 bridge the server/routes/tui.ts uses to publish TuiEvent.CommandExecute
// so the workspace TuiPlugin (packages/opencode/src/plugin/tui/altimate/workspace.tsx)
// runs its post-scan flow. Feature-flagged via Flag.ALTIMATE_WORKSPACE.
import { Effect } from "effect"
import { Flag } from "@opencode-ai/core/flag/flag"
import { AltimateApi } from "@/altimate/api/client"
import { AppRuntime } from "@/effect/app-runtime"
import { EventV2Bridge } from "@/event-v2-bridge"
import { TuiEvent } from "@/server/tui-event"
import { Event as SessionEvent } from "@/session/status"
import { Log } from "@/altimate/util/log"

const workspaceLog = Log.create({ service: "altimate-workspace" })

/**
* Publish the workspace-postScan command AFTER the session goes idle, not on
* `project_scan`'s tool.execute.after. Rationale: project_scan tool RETURNS while
* the LLM is still generating the activation-menu text; the dialog paints in
* that window but user interactions queue behind the streaming. Waiting for
* session.idle costs a few seconds of latency but sidesteps the race entirely —
* the dialog appears once things are quiet.
*
* One-shot per sessionID: pending sessions live in a Set, and when a session
* emits idle its id is removed. When the Set drains, the EventV2 listener is
* torn down via the unsubscribe returned by ``events.listen()`` so a
* permanently-installed no-op handler isn't left behind for the process
* lifetime (m4 in the consensus review). A pending arm is dropped if a
* second project_scan fires in the same session.
*/
const pendingWorkspacePromptSessions = new Set<string>()
/** The unsubscribe returned by ``events.listen()`` is an Effect (not a plain
* function) — running it removes the listener. Store the Effect and execute
* it through ``AppRuntime.runPromise`` on teardown; earlier code cast it to
* ``() => void`` and called it directly, which threw because Effects are not
* callable as functions. (cubic-dev-ai round 3.) */
let workspacePromptUnsubscribe: Effect.Effect<void, never, never> | null = null
// Guard against two concurrent scans passing the ``!workspacePromptUnsubscribe``
// check before either install completes — both would then install a listener
// and the later assignment would overwrite the first disposer, leaking the
// first listener for the process lifetime. Store the in-flight install as a
// shared promise so concurrent callers await the same result. (CR round 2.)
let workspacePromptInstall: Promise<void> | null = null

async function armWorkspacePromptOnSessionIdle(sessionID: string): Promise<void> {
pendingWorkspacePromptSessions.add(sessionID)
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
if (workspacePromptUnsubscribe) return
if (workspacePromptInstall) return workspacePromptInstall

workspacePromptInstall = (async () => {
try {
const unsubscribe = await AppRuntime.runPromise(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (P2): armingSessions is snapshotted here from pendingWorkspacePromptSessions. If a second scan calls armWorkspacePromptOnSessionIdle and adds its sessionID to pendingWorkspacePromptSessions after this snapshot but before the install completes, that session ID won't be in armingSessions. When the install fails, the catch block clears only armingSessions — the late-arriving session ID stays in pendingWorkspacePromptSessions as a permanent orphan that can never fire the workspace prompt.

Fix: replace for (const sid of armingSessions) in the catch with pendingWorkspacePromptSessions.clear() — on install failure, all pending sessions should be drained so they can re-arm on the next scan.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 12e47f53 — replaced the snapshot-and-iterate with pendingWorkspacePromptSessions.clear() in the install-failure catch. A late-arriving armWorkspacePromptOnSessionIdle between the snapshot and the failure would have left a permanent orphan; clearing the full set drains everyone.

EventV2Bridge.Service.use((events) =>
events.listen((event) =>
Effect.gen(function* () {
// Subscribe to ``Event.Status`` (session/status.ts:42, defined
// via ``EventV2.define``) rather than ``Event.Idle`` — the
// latter is marked ``// deprecated`` at session/status.ts:49
// and is only kept around for the legacy Bus SSE mirror at
// session/status.ts:176. Filtering ``event.data.status.type
// === "idle"`` gives us the same trigger without riding the
// deprecated event. (harness-bot round 8.)
if (event.type !== SessionEvent.Status.type) return
const data = event.data as
| { sessionID?: string; status?: { type?: string } }
| undefined
if (data?.status?.type !== "idle") return
const sid = data.sessionID
if (!sid || !pendingWorkspacePromptSessions.has(sid)) return
pendingWorkspacePromptSessions.delete(sid)
yield* events.publish(TuiEvent.CommandExecute, {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thought: SessionEvent.Idle is the deprecated event API (flagged in prior review rounds). The concern is that when SessionEvent.Idle is eventually removed in a cleanup pass, this event.type !== SessionEvent.Idle.type check will silently stop matching — the pendingWorkspacePromptSessions Set will never drain, causing a memory/listener leak and the workspace prompt will never fire.

The migration path is to check Event.Status with an idle-type guard instead. At minimum, add a // TODO: migrate to Event.Status idle check — SessionEvent.Idle is deprecated here so the cleanup author knows this needs updating.

command: "altimate.workspace.postScan",
})
Comment on lines +85 to +87

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve the originating location when publishing the prompt

When the scan runs through a server hosting multiple projects or an explicit opencode workspace, this listener republishes the command without event.location; because it was installed through the global AppRuntime, EventV2Bridge cannot infer an instance/workspace and emits undefined routing metadata. The TUI handler at packages/tui/src/app.tsx:1209-1212 consequently either dispatches the prompt in every default-workspace TUI or drops it when a workspace is selected, so the dialog can target the wrong directory or never appear. Publish with the idle event's location, or re-enter through a context-preserving bridge.

AGENTS.md reference: packages/opencode/AGENTS.md:L127-L129

Useful? React with 👍 / 👎.

// Once the Set drains, tear the listener down. A later scan
// that adds a new pending session re-arms it from scratch.
// ``teardown`` is an Effect — run it through the app runtime,
// don't call it as a function. (cubic round 3.)
if (pendingWorkspacePromptSessions.size === 0 && workspacePromptUnsubscribe) {
const teardown = workspacePromptUnsubscribe
workspacePromptUnsubscribe = null
AppRuntime.runPromise(teardown).catch((err) => {
workspaceLog.warn("session-idle listener teardown failed", {
err: String(err),
})
})
}
}),
),
),
)
workspacePromptUnsubscribe = unsubscribe
} catch (err) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[SUGGESTION]: The install-failure machinery is effectively unreachable — simplify

events.listen is Effect.sync and cannot fail at runtime; the only way into this catch is AppRuntime layer construction failing, which would equally fail every subsequent call. The armingSessions snapshot (line 69), the drain loop here, and the never-rejecting Promise<void> whose sole caller discards it with void (line 244) are speculative generality — and the snapshot is structurally incomplete anyway (fast-path joiners returning at line 62 are never captured). Returning plain void with just the shared-install-promise guard would remove the whole block.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

// Install failed — drain EVERY pending session, not just the ones
// snapshotted at install-time. A late-arriving ``armWorkspacePromptOn
// SessionIdle`` between the snapshot and the failure would otherwise
// be a permanent orphan (its sessionID stays in the pending set but
// no listener will ever fire for it). (harness-bot round 8.)
pendingWorkspacePromptSessions.clear()
workspaceLog.warn("session-idle listener install failed", { err: String(err) })
} finally {
workspacePromptInstall = null
}
})()
return workspacePromptInstall
}
// altimate_change end

const ONBOARD_CONNECT = "onboard-connect"

Expand Down Expand Up @@ -134,6 +240,14 @@ export async function OnboardingTelemetryPlugin(_input: PluginInput): Promise<Ho
},
input.sessionID,
)
// altimate_change start — AI-8398 workspaces post-scan prompt trigger.
// ARM (don't publish yet) — the dialog fires when the session goes idle,
// not the moment project_scan returns. See armWorkspacePromptOnSessionIdle
// above for why the immediate publish raced the LLM's ongoing streaming.
if (Flag.ALTIMATE_WORKSPACE && (await AltimateApi.isConfigured().catch(() => false))) {
void armWorkspacePromptOnSessionIdle(input.sessionID)
}
// altimate_change end
return
}

Expand Down
7 changes: 6 additions & 1 deletion packages/opencode/src/altimate/tools/project-scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,8 +173,13 @@ export async function detectGit(): Promise<GitInfo> {
* SSH-form remotes (`git@github.com:owner/repo.git`) have no userinfo
* concept and are left untouched. URLs we can't parse are dropped to
* undefined (better to lose the breadcrumb than leak creds).
*
* Exported so the workspace TuiPlugin (packages/opencode/src/plugin/tui/
* altimate/workspace.tsx) can reuse the exact same scrubbing rules when
* deriving the project's git remote for the post-scan prompt — the alt
* of duplicating the logic risks the two callers drifting.
*/
function stripGitRemoteCredentials(url: string): string | undefined {
export function stripGitRemoteCredentials(url: string): string | undefined {
if (!url) return undefined
// SSH form: `git@host:path` — no creds to strip.
if (/^[\w.-]+@[\w.-]+:/.test(url) && !url.includes("://")) return url
Expand Down
Loading
Loading