Skip to content

Commit f73ed57

Browse files
Merge origin/main into fix/test-leaked-skill-syncs
Resolves the import conflict in plugin/workspace.test.ts with the main change to the link pickers: both sides only added imports. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_0174HUYiaceApqHP7EKMNgnb
2 parents 607ed6b + a94792a commit f73ed57

10 files changed

Lines changed: 745 additions & 45 deletions

File tree

‎docs/docs/usage/cli.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ Configuration can be controlled via environment variables:
9797
| `ALTIMATE_CLI_DISABLE_PRUNE` | Disable database pruning |
9898
| `ALTIMATE_CLI_DISABLE_MODELS_FETCH` | Don't fetch models from models.dev |
9999
| `ALTIMATE_WORKSPACE` | Opt into the workspace pilot (`1`). Off by default; nothing about workspaces is active without it |
100+
| `ALTIMATE_WORKSPACE_WEB_URL` | Development only: the base URL of a local web app's Workspaces pages, used instead of the tenant's for `link`'s browser hand-off and workspace links. Include the mount path, e.g. `http://acme.localhost:3000/workspaces` |
100101
| `ALTIMATE_INTEGRATIONS` | Set to `local` to keep warehouse tools local rather than routing them through a bound workspace's engine |
101102
| `ALTIMATE_CODE_SERVE` | Set to `1` by `altimate-code serve` itself, whether the IDE extension or you launched it. Marks that process as the extension's host — so it is the one that reads the pin variables — and is stripped from every child the bash and shell tools start |
102103
| `ALTIMATE_PINNED_WORKSPACE_ID` / `_NAME` / `_ROOT` | Set together by the IDE extension on `serve`: the workspace selected in its panel and the folder it applies to. All three or none — a partial pin is refused rather than ignored. Read only when `ALTIMATE_CODE_SERVE` is set; never persisted; stripped from child processes |

‎packages/opencode/src/altimate/workspace/api-client.ts‎

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,14 @@ export class WorkspaceApiError extends Error {
159159
}
160160
}
161161

162+
/** A credential captured once and passed to every request of one flow, so the flow cannot
163+
* drift onto another account part-way: workspace and user ids are per tenant. */
164+
export interface ActAs {
165+
url: string
166+
instance: string
167+
apiKey: string
168+
}
169+
162170
async function creds(): Promise<{ url: string; instance: string; apiKey: string }> {
163171
if (!(await AltimateApi.isConfigured())) throw new NotConfiguredError()
164172
const c = await AltimateApi.getCredentials()
@@ -521,23 +529,31 @@ export namespace WorkspaceApi {
521529
return { id, name: input.name }
522530
}
523531

532+
/** The ambient credential as an `ActAs`, or null when none is configured. */
533+
export async function captureCredentials(): Promise<ActAs | null> {
534+
return creds().catch(() => null)
535+
}
536+
524537
export async function bindExisting(
525538
datamateId: number,
526539
identifier: ProjectIdentifier,
540+
actAs?: ActAs,
527541
): Promise<BindingResponse> {
528542
return req<BindingResponse>("POST", "/bind", {
529543
body: {
530544
datamate_id: datamateId,
531545
repo_remote: identifier.repoRemote ?? null,
532546
project_path: identifier.projectPath ?? null,
533547
},
548+
...(actAs ? { actAs } : {}),
534549
})
535550
}
536551

537552
export async function rebindByRemote(input: {
538553
remote: string
539554
targetDatamateId: number
540555
expectedCurrentDatamateId?: number
556+
actAs?: ActAs
541557
}): Promise<BindingResponse> {
542558
return req<BindingResponse>("PUT", "/by-remote", {
543559
body: {
@@ -547,6 +563,7 @@ export namespace WorkspaceApi {
547563
? { expected_current_datamate_id: input.expectedCurrentDatamateId }
548564
: {}),
549565
},
566+
...(input.actAs ? { actAs: input.actAs } : {}),
550567
})
551568
}
552569

@@ -556,6 +573,7 @@ export namespace WorkspaceApi {
556573
projectPath: string
557574
targetDatamateId: number
558575
expectedCurrentDatamateId?: number
576+
actAs?: ActAs
559577
}): Promise<BindingResponse> {
560578
return req<BindingResponse>("PUT", "/by-path", {
561579
body: {
@@ -565,6 +583,7 @@ export namespace WorkspaceApi {
565583
? { expected_current_datamate_id: input.expectedCurrentDatamateId }
566584
: {}),
567585
},
586+
...(input.actAs ? { actAs: input.actAs } : {}),
568587
})
569588
}
570589

@@ -627,8 +646,8 @@ export namespace WorkspaceApi {
627646
/** The caller's own user id, from ``GET /users/me``. Needed wherever the
628647
* client must compare ownership — skill attachment requires the caller to
629648
* OWN the workspace, and the credentials carry no user id of their own. */
630-
export async function whoami(): Promise<number> {
631-
const me = await req<{ id?: unknown }>("GET", "/me", { base: "/users" })
649+
export async function whoami(actAs?: ActAs): Promise<number> {
650+
const me = await req<{ id?: unknown }>("GET", "/me", { base: "/users", ...(actAs ? { actAs } : {}) })
632651
const id = Number(me?.id)
633652
if (!Number.isInteger(id) || id <= 0) throw new WorkspaceApiError("The server did not say who this account is.")
634653
return id

‎packages/opencode/src/altimate/workspace/workspace-name.ts‎

Lines changed: 90 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
//
33
// The one piece of workspace text handling that BOTH realms need: the session
44
// code renders the name into the system prompt, and the TUI plugin renders it
5-
// into a dialog header. Kept free of imports and module state on purpose —
5+
// into a dialog header. Kept free of imports and mutable module state on purpose —
66
// `precedence.ts`, where this lived, is server-side only (see its header), and
77
// a plugin importing it would load a second copy of that module's state into
88
// the plugin realm.
@@ -15,10 +15,7 @@
1515
* what the model reads. */
1616
export const MAX_WORKSPACE_NAME_CHARS = 80
1717
export function inertWorkspaceName(name: string): string {
18-
const cleaned = name
19-
.replace(/[\u0000-\u001F\u007F-\u009F\u2028\u2029]+/g, " ")
20-
.replace(/\s+/g, " ")
21-
.trim()
18+
const cleaned = oneLine(name)
2219
const points = Array.from(cleaned)
2320
return points.length > MAX_WORKSPACE_NAME_CHARS ? points.slice(0, MAX_WORKSPACE_NAME_CHARS - 1).join("") + "…" : cleaned
2421
}
@@ -47,3 +44,91 @@ export function workspaceLabel(name: string, id: string | undefined, budget = MA
4744
// so it is what survives.
4845
return label.length > budget ? suffix.trim() : label
4946
}
47+
48+
/** A name on one line: control characters (C0, DEL and C1) and the Unicode line and
49+
* paragraph separators become spaces, runs of whitespace collapse, ends are trimmed.
50+
* The one normalisation both the rendered and the compared name start from. */
51+
function oneLine(name: string): string {
52+
return name
53+
.replace(/[\u0000-\u001F\u007F-\u009F\u2028\u2029]+/g, " ")
54+
.replace(/\s+/g, " ")
55+
.trim()
56+
}
57+
58+
/** A name as the link pickers compare it: on one line, and the sharp S spelled out,
59+
* which collation otherwise keeps apart from "ss". */
60+
function comparableName(name: string): string {
61+
return oneLine(name).replace(/[ßẞ]/g, "ss")
62+
}
63+
64+
/** The bidi controls (ALM, LRM/RLM, LRE..RLO, LRI..PDI): harmless in a link, but they
65+
* can visually reverse or reorder a displayed name. */
66+
const BIDI_CONTROLS = /[\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/g
67+
export function stripBidiControls(text: string): string {
68+
return text.replace(BIDI_CONTROLS, "")
69+
}
70+
71+
/** A workspace name as a dialog shows it: inert, and with no bidi controls. */
72+
export function displayWorkspaceName(name: string): string {
73+
return stripBidiControls(inertWorkspaceName(name))
74+
}
75+
76+
/** One collator for every comparison, pinned to `en`: an unpinned one follows the host
77+
* locale, and under Turkish `I` no longer pairs with `i`, under Danish `aa` equals `å`. */
78+
export const NAME_COLLATOR = new Intl.Collator("en", { sensitivity: "accent", usage: "search" })
79+
80+
export interface Namesakes<T> {
81+
/** Every listed workspace with the name a quick create would use, in list order. */
82+
all: T[]
83+
/** The first of them the caller owns, or undefined. The only one a picker opens on: a plain
84+
* Enter on a namesake links to it and sends this machine's memory there, which must not
85+
* happen to a colleague's workspace by accident. */
86+
own: T | undefined
87+
}
88+
89+
/** The listed workspaces already named what a quick create would call this project.
90+
*
91+
* Names compare by Unicode collation, ignoring case but not accents: `Straße` matches
92+
* `STRASSE`, `ΟΔΟΣ` matches `οδος` (final sigma) and `finance` matches `FINANCE` (ligature),
93+
* all through collation, while `ı` and `i` or `café` and `cafe` stay different.
94+
* Characters collation ignores, such as zero-width and bidi controls, do not make a name
95+
* different. Ownership counts only when both the owner and the caller are known. */
96+
export function findNamesakes<T extends { name: string; ownerId?: number }>(
97+
list: readonly T[],
98+
proposedName: string,
99+
userId: number | undefined,
100+
): Namesakes<T> {
101+
const target = comparableName(proposedName)
102+
if (!target) return { all: [], own: undefined }
103+
const all = list.filter((workspace) => NAME_COLLATOR.compare(comparableName(workspace.name), target) === 0)
104+
const own = userId === undefined ? undefined : all.find((workspace) => workspace.ownerId === userId)
105+
return { all, own }
106+
}
107+
108+
/** The hint a picker shows on a namesake's row, or undefined for any other row. */
109+
export function namesakeHint<T extends { name: string; ownerId?: number }>(
110+
workspace: T,
111+
namesakes: Namesakes<T>,
112+
userId: number | undefined,
113+
): string | undefined {
114+
if (!namesakes.all.includes(workspace)) return undefined
115+
const someoneElses = userId !== undefined && workspace.ownerId !== undefined && workspace.ownerId !== userId
116+
return someoneElses ? "same name, owned by someone else" : "same name as this project"
117+
}
118+
119+
/** Where a link picker opens: the current link, else the caller's own namesake, else create. */
120+
export function linkPickerOpensOn<T extends { id: number; name: string; ownerId?: number }>(
121+
currentId: number | undefined,
122+
namesakes: Namesakes<T>,
123+
): number | "create" {
124+
return currentId ?? namesakes.own?.id ?? "create"
125+
}
126+
127+
/** Whether a choice in a link picker or the setup dialog must confirm first: both create
128+
* paths start from the project's name, so either one would make a second namesake. */
129+
export function confirmsNamesake(
130+
choice: "create" | "browser" | "workspace",
131+
namesakes: Namesakes<unknown>,
132+
): boolean {
133+
return choice !== "workspace" && namesakes.all.length > 0
134+
}

0 commit comments

Comments
 (0)