Skip to content
Open
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
110 changes: 110 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,108 @@
// 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

// Capture the set of pending sessions at install-time so an install
// failure drains EVERY caller that awaited this install, not just the
// one whose sessionID we happen to be handling. Later waiters would
// otherwise see success from the shared promise and stop retrying,
// leaving permanently-stale entries in the pending set. (cubic round 3.)
const armingSessions = new Set(pendingWorkspacePromptSessions)

workspacePromptInstall = (async () => {
try {
const unsubscribe = await AppRuntime.runPromise(
EventV2Bridge.Service.use((events) =>
events.listen((event) =>
Effect.gen(function* () {
if (event.type !== SessionEvent.Idle.type) return

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]: Subscribing to the deprecated SessionEvent.Idle

Event.Idle is marked // deprecated in src/session/status.ts:49; the supported signal is Event.Status with status.type === "idle" (published in the same status set call, so behavior is equivalent today). When the deprecated event is eventually removed, the post-scan workspace prompt silently stops arming with no compile-time signal.


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

const sid = (event.data as { sessionID?: string } | undefined)?.sessionID
if (!sid || !pendingWorkspacePromptSessions.has(sid)) return
pendingWorkspacePromptSessions.delete(sid)
yield* events.publish(TuiEvent.CommandExecute, {
command: "altimate.workspace.postScan",
})
Comment on lines +81 to +83

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 — drop every session that was waiting on this install
// so the next scan retries from scratch. Dropping only the current
// caller's ID would leave later waiters (already resolved by the
// shared install promise) with permanently-stale pending entries.
// (cubic round 3.)
for (const sid of armingSessions) pendingWorkspacePromptSessions.delete(sid)
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 +236,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