Skip to content
28 changes: 28 additions & 0 deletions packages/opencode/src/altimate/workspace/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,34 @@ export namespace WorkspaceApi {
return null
}

/** Detach this project from its workspace, server-side.
*
* Returns false when the server had no active binding to remove — the project
* was already unlinked, by someone else or on another machine. That is a
* distinct outcome from "removed", not an error, so the caller can tell the
* user which happened.
*
* A local-only unlink is not possible: ``lookupBinding`` re-asks the server
* whenever the cache misses, so a row dropped only on disk comes straight back
* on the next resolve. */
export async function unbindProject(id: ProjectIdentifier): Promise<boolean> {
const query: Record<string, string> = {}
// Send exactly one identifier. The endpoint answers 409 when both are given
// and they name different bindings, and preferring the remote matches how
// ``getBindingForProject`` resolves — so unlink removes the binding that
// lookup would have found.
if (id.repoRemote) query.repo_remote = id.repoRemote
else if (id.projectPath) query.project_path = id.projectPath
else return false
try {
await req<unknown>("DELETE", "/", { query, allowEmptyBody: true })
return true
} catch (err) {
if (err instanceof NotFoundError) return false

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: When DELETE returns 404, this reports success instead of an error. manage.unlink then clears local state, so a stale or non-normalized identifier can leave the server binding intact and silently re-adopt it later; propagate 404 and retain local state.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/src/altimate/workspace/api-client.ts, line 378:

<comment>When DELETE returns 404, this reports success instead of an error. `manage.unlink` then clears local state, so a stale or non-normalized identifier can leave the server binding intact and silently re-adopt it later; propagate 404 and retain local state.</comment>

<file context>
@@ -352,6 +352,34 @@ export namespace WorkspaceApi {
+      await req<unknown>("DELETE", "/", { query, allowEmptyBody: true })
+      return true
+    } catch (err) {
+      if (err instanceof NotFoundError) return false
+      throw err
+    }
</file context>

throw err
}
}

export async function createAndBind(input: {
name: string
identifier: ProjectIdentifier
Expand Down
70 changes: 67 additions & 3 deletions packages/opencode/src/altimate/workspace/awareness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ export const MAX_SECTION_CHARS = 2_000

const HEADING = "## Workspace integrations"

const BINDING_HEADING = "## Workspace"

/** How each capability is named to the model. Keyed on the `Capability` union, so a
* new capability is a compile error here rather than an unlabelled row. */
const CAPABILITY_LABEL: Record<Capability, string> = {
Expand Down Expand Up @@ -111,6 +113,49 @@ const DISABLED_COPY: Record<NonNullable<Precedence["disabledReason"]>, string> =
"nothing-materialised": "",
}

/** Whether the workspace may be NAMED in this state. Separate from `DISABLED_COPY`
* because identity and routing are different claims: the routing directive stays
* silent unless there is something to steer, but "which workspace is this project
* linked to" is a question the model is asked directly and could not previously
* answer — nothing else puts the binding in the prompt, and no tool reports it.
*
* Keyed on the union so a new `disabledReason` is a compile error here rather than
* silently naming — or silently failing to name — a workspace. `false` for the three
* unverified states for the reason `UNVERIFIED_SECTION` gives: nothing has confirmed
* the binding those states were derived from, and under `unattributed` the engine may
* belong to a different workspace than the one the link names. `false` for the hatch
* and `pilot-off` because neither carries a name to print (see `EMPTY` in
* `precedence.ts`) — a bound project with the hatch on therefore stays unnamed, which
* is a data limitation of that call site, not a decision made here.
*
* NOTE: this deliberately breaks the "byte-identical system prompt" property that
* `DISABLED_COPY` claims for `nothing-materialised`. A project bound to a workspace
* that materialised no integrations is exactly the case users hit — a freshly created
* workspace — and it is the case where being told nothing is most confusing. */
const NAMES_BINDING: Record<NonNullable<Precedence["disabledReason"]>, boolean> = {
"pilot-off": false,
"escape-hatch": false,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: When a bound project has routing disabled by the escape hatch or attribution fails, this table suppresses the new identity line even though the binding is known. Preserve the sanitized bound name and ID in these snapshots, then render identity independently of routing.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/src/altimate/workspace/awareness.ts, line 137:

<comment>When a bound project has routing disabled by the escape hatch or attribution fails, this table suppresses the new identity line even though the binding is known. Preserve the sanitized bound name and ID in these snapshots, then render identity independently of routing.</comment>

<file context>
@@ -111,6 +113,49 @@ const DISABLED_COPY: Record<NonNullable<Precedence["disabledReason"]>, string> =
+ * workspace — and it is the case where being told nothing is most confusing. */
+const NAMES_BINDING: Record<NonNullable<Precedence["disabledReason"]>, boolean> = {
+  "pilot-off": false,
+  "escape-hatch": false,
+  unbound: false,
+  "binding-unreadable": false,
</file context>

unbound: false,
"binding-unreadable": false,
unattributed: false,
"derive-failed": false,
"nothing-materialised": true,
}

/** The identity line: what this project is linked to, independent of whether anything
* is being routed. Empty when the state may not name a binding, or when the snapshot
* carries no name to print. */
function bindingSection(precedence: Precedence): string {
const nameable = precedence.enabled || (precedence.disabledReason ? NAMES_BINDING[precedence.disabledReason] : false)
if (!nameable) return ""
if (!inertWorkspaceName(precedence.workspaceName)) 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.

P2: When sanitization leaves the workspace name empty, this guard drops the identity even if the binding ID is known. Provide a bounded fallback display label at the snapshot boundary and retain the quoted identity line with its ID instead of allowing a customer-authored name to erase it.

(Based on your team's feedback about workspace identity labels.)

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/src/altimate/workspace/awareness.ts, line 151:

<comment>When sanitization leaves the workspace name empty, this guard drops the identity even if the binding ID is known. Provide a bounded fallback display label at the snapshot boundary and retain the quoted identity line with its ID instead of allowing a customer-authored name to erase it.

(Based on your team's feedback about workspace identity labels.) </comment>

<file context>
@@ -111,6 +113,49 @@ const DISABLED_COPY: Record<NonNullable<Precedence["disabledReason"]>, string> =
+function bindingSection(precedence: Precedence): string {
+  const nameable = precedence.enabled || (precedence.disabledReason ? NAMES_BINDING[precedence.disabledReason] : false)
+  if (!nameable) return ""
+  if (!inertWorkspaceName(precedence.workspaceName)) return ""
+  return [
+    BINDING_HEADING,
</file context>

return [
BINDING_HEADING,
"",
`This project is linked to Altimate workspace ${workspaceLabel(precedence.workspaceName, precedence.workspaceId)}.`,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: When disabledReason is nothing-materialised, this line receives a snapshot with no workspaceId, so the new identity section omits the stable workspace ID. Preserve the binding ID on that disabled snapshot (or otherwise pass it through) so every rendered workspace identity includes (id ...).

(Based on your team's feedback about workspace identity labels.)

View Feedback

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/src/altimate/workspace/awareness.ts, line 155:

<comment>When `disabledReason` is `nothing-materialised`, this line receives a snapshot with no `workspaceId`, so the new identity section omits the stable workspace ID. Preserve the binding ID on that disabled snapshot (or otherwise pass it through) so every rendered workspace identity includes `(id ...)`.

(Based on your team's feedback about workspace identity labels.) </comment>

<file context>
@@ -111,6 +113,49 @@ const DISABLED_COPY: Record<NonNullable<Precedence["disabledReason"]>, string> =
+  return [
+    BINDING_HEADING,
+    "",
+    `This project is linked to Altimate workspace ${workspaceLabel(precedence.workspaceName, precedence.workspaceId)}.`,
+  ].join("\n")
+}
</file context>

].join("\n")
}

/**
* Render the section, or "" when there is nothing to steer.
*
Expand All @@ -130,6 +175,20 @@ const DISABLED_COPY: Record<NonNullable<Precedence["disabledReason"]>, string> =
*/
export function systemSection(precedence: Precedence | undefined): string {
if (!precedence) return ""
// Identity first, then routing. Either half can be empty; both empty renders "".
// The identity line is charged against MAX_SECTION_CHARS rather than added on top:
// the cap exists to bound what this module injects, so letting a new part sit
// outside it would raise the real ceiling silently.
const binding = bindingSection(precedence)
const routing = routingSection(precedence, binding ? binding.length + SEPARATOR.length : 0)
return [binding, routing].filter(Boolean).join(SEPARATOR)
}

const SEPARATOR = "\n\n"

/** The routing directive. Unchanged contract: silent unless the workspace is really
* routing, so the model is never steered toward tools it should not use. */
function routingSection(precedence: Precedence, reserved = 0): string {
if (!precedence.enabled) return precedence.disabledReason ? DISABLED_COPY[precedence.disabledReason] : ""

const served = servedInventory(precedence)
Expand All @@ -147,7 +206,7 @@ export function systemSection(precedence: Precedence | undefined): string {
return `- ${type} — ${servedPart}${localPart}`
})

return assemble(precedence.workspaceName, precedence.workspaceId, typeLines)
return assemble(precedence.workspaceName, precedence.workspaceId, typeLines, reserved)
}

/** The workspace name is customer-authored and lands in the system prompt — the
Expand All @@ -171,7 +230,12 @@ function workspaceLabel(name: string, id: string | undefined): string {
* be partial instead, and the prohibition is kept only for types the workspace does
* not serve. The count is stated once, on the list where it belongs; the converse
* carries only what the model should DO about the omission. */
function assemble(workspaceName: string, workspaceId: string | undefined, typeLines: string[]): string {
function assemble(
workspaceName: string,
workspaceId: string | undefined,
typeLines: string[],
reserved = 0,
): string {
const label = workspaceLabel(workspaceName, workspaceId)
const render = (lines: string[]) => {
const omitted = typeLines.length - lines.length
Expand Down Expand Up @@ -200,7 +264,7 @@ function assemble(workspaceName: string, workspaceId: string | undefined, typeLi

let lines = typeLines
let out = render(lines)
while (out.length > MAX_SECTION_CHARS && lines.length > 0) {
while (out.length + reserved > MAX_SECTION_CHARS && lines.length > 0) {
lines = lines.slice(0, -1)
out = render(lines)
}
Expand Down
Loading
Loading