Skip to content
Open
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
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)
if (workspacePromptUnsubscribe) return
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
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
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",
})
// 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) {
// 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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

WARNING: Install-failure drain misses sessions that joined the shared install promise after the snapshot

A second scan arriving while the install is in flight takes the early return at line 62 (if (workspacePromptInstall) return workspacePromptInstall) after already adding its sessionID at line 60 — so it is not in armingSessions, yet its caller still resolves the shared install promise as success. If the install then fails, this loop drains only the snapshot, leaving that session in pendingWorkspacePromptSessions with no listener installed: its idle event never publishes the postScan prompt, and the stale entry lingers until an unrelated later scan re-arms. Since a failed install leaves no listener, every pending entry is uncovered regardless of when it was added — clear() is both complete and lets the armingSessions snapshot be deleted:

Suggested change
for (const sid of armingSessions) pendingWorkspacePromptSessions.delete(sid)
pendingWorkspacePromptSessions.clear()

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

workspaceLog.warn("session-idle listener install failed", { err: String(err) })
} finally {
workspacePromptInstall = null
}
})()
return workspacePromptInstall
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// 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