Skip to content

Commit e495090

Browse files
sahrizviclaude
andauthored
feat(workspace): headless workspace commands and a non-interactive link (#1396)
* feat(workspace): headless `workspace` commands and a non-interactive `link` Refresh, Sync, Unlink and status existed only in the TUI's `/workspace` menu (and Refresh/Sync over `serve`), and `link` refused to run without a TTY, so a setup script, devcontainer or CI job had no way to link a project or see its state. - `altimate-code workspace status [--json]`: linked workspace, unsynced memory, and when skills last synced. Resolves through the service when there is no cached row, so a fresh clone reports its server-side link. - `altimate-code workspace refresh | sync | unlink [--yes]`: thin wrappers over `Manage.refresh/sync/unlink`, the same code as the menu and the serve routes. `unlink` asks on a terminal and requires `--yes` otherwise. - `altimate-code link --workspace <name|id>` and `--create [name]` link without prompting; replacing an existing link requires `--yes`, and a failed pre-check stops instead of retrying a conflict as a re-link. - Exit codes: 0 ok, 1 failed, 2 request to change (not signed in, missing `--yes`, ambiguous name), 3 not linked. The `workspace` subcommands take `--json` and `--directory`. - Without a terminal, link's spinners print plain lines instead of redrawing. - Link's "did not reach the workspace" hints now name `altimate-code workspace sync` alongside the TUI menu. `--shared` is not included: the create-and-bind endpoint has no privacy field, so a CLI-created workspace stays private until shared from the web app. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018fJ3X7pcGT4R9yzjsJnqsV * test: give the trace-context MCP stub the members #1308 added `trace-context-loop.test.ts` (#1400) stubs `MCP.Service` without `listMeta` and `snapshot`, which #1308 added to the interface, so `tsgo` fails on main and the pre-push typecheck blocks any branch that merges it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018fJ3X7pcGT4R9yzjsJnqsV * fix(workspace): outage is not "not linked", sanitize names, idempotent link --create, handler tests (review) Consensus review fixes: status/refresh/sync/unlink resolve the link first (service unreachable exits 1, a confirmed unlink exits 3); sync refuses an adopted link; names stripped of control characters; unlink that leaves skills behind exits 1; `--json` ok matches exit 0; `link --create` is a no-op when already linked to that name and refuses an existing namesake unless `--allow-duplicate`; headless create does not open a browser; `workspace` recorded in telemetry; handler-level tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018fJ3X7pcGT4R9yzjsJnqsV * fix(workspace): confirm an existing link on this machine, and sync only to the verified one (review) - `link --workspace`/`--create` for the workspace already linked records the link as confirmed here instead of returning with it still unconfirmed, so `workspace sync` accepts it - `link`'s pre-check looks up the binding with the same captured credential as the list and the bind - The same-name `--create` no-op runs before the workspace list is fetched, and `--allow-duplicate` skips it - `workspace sync` passes the binding it verified to the memory sweep, which refuses an unconfirmed one, and refuses a link the service could not confirm just now (exit 1) - `workspace unlink` of a project the service already detached still clears local workspace state - `status` says when a "not linked" answer came from the recent-miss cache, when a link is not confirmed on this machine, and when memory could not be read (instead of "off") - A bootstrap failure after the command already reported goes to stderr only; the directory in that message is stripped of control characters - Docs: exit-code bullets merged; `status` details described as available when known - Tests restore the prior `process.exitCode` Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018fJ3X7pcGT4R9yzjsJnqsV * fix(workspace): unlink never deletes on a "not linked" answer; confirm a link as the captured credential (review) - `workspace unlink` touches nothing when the service says the project is not linked: `Manage.unlink` deletes server-side, and the answer can be a cached miss that predates a new link. Reverts the local cleanup added in the previous commit; the skill sync already takes skills out of service on a confirmed unbind - `link` confirming an existing link derives the account from the captured credential, and a refused or failed record exits 1 instead of reporting the link as confirmed - Tests assert the list and bind run as the captured credential too Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018fJ3X7pcGT4R9yzjsJnqsV * fix(workspace): judge a confirmed link by the row written, not the seed outcome (review) `recordApprovedBinding` returns `account-changed` both before its write and after it, when only the memory seed was skipped. `link` now checks for an approved, non-adopted row under the captured account (`isApprovedRow`) and reports the seed outcome separately. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018fJ3X7pcGT4R9yzjsJnqsV * fix(workspace): count only the approval this `link` call wrote (review) An approved row recorded earlier no longer vouches for a later write that was refused: `isApprovedRow` also matches the `linkedAt` this call stamped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018fJ3X7pcGT4R9yzjsJnqsV --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent e3b784a commit e495090

9 files changed

Lines changed: 1179 additions & 39 deletions

File tree

‎docs/docs/usage/cli.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ altimate --agent analyst
4040
| `import` | Import session data |
4141
| `session` | Session management |
4242
| `link` | Link this project to an Altimate workspace (pilot, requires `ALTIMATE_WORKSPACE=1`) |
43+
| `workspace` | Show, refresh, sync or unlink this project's workspace from a shell (pilot, requires `ALTIMATE_WORKSPACE=1`) |
4344
| `trace` | List and view session traces (recordings of agent sessions) |
4445
| `github` | GitHub integration |
4546
| `pr` | Pull request tools |
@@ -52,6 +53,11 @@ altimate --agent analyst
5253
Workspace features are off unless `ALTIMATE_WORKSPACE=1` is set. With it:
5354

5455
- `altimate-code link` links the current project to a workspace (or creates one). The sidebar then names the workspace and shows how many memories are not yet synced and when skills last synced.
56+
- From a shell, script or CI job, without the TUI:
57+
- `altimate-code link --workspace <name|id>` links to an existing workspace (an id is matched before a name), and `altimate-code link --create [name]` creates one (named after the repo by default) and links to it. Neither prompts. If the project is already linked to a different workspace they refuse unless `--yes` is passed. Running `--create` again when the project is already linked to a workspace of that name creates nothing, so it is safe in a devcontainer's setup command; if another workspace already has that name it refuses and names its id, unless `--allow-duplicate` is passed. Linking to the workspace the project is already linked to confirms that link on this machine, which a fresh clone needs before `workspace sync` sends its memory.
58+
- `altimate-code workspace status [--json]` shows the linked workspace and, when known, how many saved memories have not reached it and when skills last synced.
59+
- `altimate-code workspace refresh` pulls the workspace's skills (memory is loaded by the next session started in the project); `altimate-code workspace sync` sends memory the workspace has not received; `altimate-code workspace unlink [--yes]` detaches the project (`--yes` is required without a terminal).
60+
- These commands accept `--directory`. Exit codes: `0` done, `1` failed (including when the workspace service could not be reached), `2` a request to change (not signed in, a re-link or unlink without `--yes` and no terminal to confirm on, or a `sync` to a link not yet confirmed on this machine), `3` the project is not linked. With `--json`, `ok` is `true` exactly when the exit code is `0`.
5561
- `/workspace` in the TUI opens a menu: **Refresh** pulls the workspace's skills and memory into this project, **Sync** re-sends local memory the workspace never received, **Open in browser** (when a web URL is available) shows the workspace on the web, **Switch workspace** relinks the project, **Unlink** detaches it. In a project that is not linked yet, it offers **Link to a workspace** instead.
5662
- `altimate-code skill publish <name>` uploads a project skill to the linked workspace; see [Skills](../configure/skills.md#cli-commands).
5763
- In an ordinary session the agent is told every turn which workspace the project is linked to — or that none is, or that the link could not be verified just now. In an extension-pinned session it is told the pinned workspace instead, and that it differs from the project's own link. Either way "which workspace am I in?" has an answer, and the agent is told not to confuse it with a Databricks workspace or an IDE workspace folder.

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

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -378,9 +378,9 @@ export { req as altimateRequest }
378378

379379
export namespace WorkspaceApi {
380380
/** Server-authoritative pre-check by git remote. Returns null on 404. */
381-
export async function getBindingForRemote(remote: string): Promise<GetBindingResponse | null> {
381+
export async function getBindingForRemote(remote: string, actAs?: ActAs): Promise<GetBindingResponse | null> {
382382
try {
383-
return await req<GetBindingResponse>("GET", "/by-remote", { query: { repo_remote: remote } })
383+
return await req<GetBindingResponse>("GET", "/by-remote", { query: { repo_remote: remote }, ...(actAs ? { actAs } : {}) })
384384
} catch (err) {
385385
if (err instanceof NotFoundError) return null
386386
throw err
@@ -389,9 +389,9 @@ export namespace WorkspaceApi {
389389

390390
/** Symmetric pre-check by absolute project directory path (for projects
391391
* without a git remote). Returns null on 404. */
392-
export async function getBindingForPath(projectPath: string): Promise<GetBindingResponse | null> {
392+
export async function getBindingForPath(projectPath: string, actAs?: ActAs): Promise<GetBindingResponse | null> {
393393
try {
394-
return await req<GetBindingResponse>("GET", "/by-path", { query: { project_path: projectPath } })
394+
return await req<GetBindingResponse>("GET", "/by-path", { query: { project_path: projectPath }, ...(actAs ? { actAs } : {}) })
395395
} catch (err) {
396396
if (err instanceof NotFoundError) return null
397397
throw err
@@ -403,13 +403,13 @@ export namespace WorkspaceApi {
403403
* picks the right endpoint even if the current identifier's remote has
404404
* changed since the binding was created (M3). Both fields on the
405405
* identifier are optional but at least one must be present. */
406-
export async function getBindingForProject(id: ProjectIdentifier): Promise<ProjectBindingLookup | null> {
406+
export async function getBindingForProject(id: ProjectIdentifier, actAs?: ActAs): Promise<ProjectBindingLookup | null> {
407407
if (id.repoRemote) {
408-
const hit = await getBindingForRemote(id.repoRemote)
408+
const hit = await getBindingForRemote(id.repoRemote, actAs)
409409
if (hit) return { ...hit, matchedBy: "remote" }
410410
}
411411
if (id.projectPath) {
412-
const hit = await getBindingForPath(id.projectPath)
412+
const hit = await getBindingForPath(id.projectPath, actAs)
413413
if (hit) return { ...hit, matchedBy: "path" }
414414
}
415415
return null

‎packages/opencode/src/altimate/workspace/manage.ts‎

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,9 @@ export interface StatusReport {
5050
* sidebar, when the service could not be reached. Rendering that as
5151
* 0 would tell the user their memory is current when nobody knows. */
5252
memory: { local: number; unsynced: number | null } | null
53+
/** Set when memory is on but the local store or its index could not be read: `memory` is then null, which
54+
* alone would read as "off". */
55+
memoryUnreadable?: true
5356
skillsEnabled: boolean
5457
/** When workspace skills last synced successfully, or null if they have not in
5558
* this process. Null is genuinely "unknown", not "never" — the store is
@@ -85,7 +88,14 @@ export interface SyncReport {
8588
* and only one of them is the workspace's memory toggle; a toast that said
8689
* "memory is off" for a failed local read sent the user to a setting that was
8790
* fine. */
88-
gatedBecause?: "flag-off" | "no-binding" | "pin-unresolved" | "memory-off" | "read-failed" | "setting-unavailable"
91+
gatedBecause?:
92+
| "flag-off"
93+
| "no-binding"
94+
| "pin-unresolved"
95+
| "memory-off"
96+
| "read-failed"
97+
| "setting-unavailable"
98+
| "not-approved"
8999
sent: number
90100
failed: number
91101
/** Already present in the workspace at their current payload. */
@@ -139,9 +149,11 @@ export async function status(
139149
? await resolveBinding(directory).catch(() => null)
140150
: ((await readLocalBinding(directory).catch(() => null)) ??
141151
(await resolveBinding(directory).catch(() => null)))
152+
const memory = await memoryCounts(directory, binding, opts.poll === true)
142153
return {
143154
binding,
144-
memory: await memoryCounts(directory, binding, opts.poll === true),
155+
memory: memory === "unreadable" ? null : memory,
156+
...(memory === "unreadable" ? { memoryUnreadable: true as const } : {}),
145157
skillsEnabled: SkillSync.isEnabled(),
146158
skillsSyncedAt: await skillsSyncedAt(directory, binding),
147159
}
@@ -223,7 +235,13 @@ export async function refresh(directory: string, sessionID?: string): Promise<Re
223235
* `backfill` is throttled and resumable — blocks already present at their current
224236
* payload are skipped — so running this when there is nothing to do costs an index
225237
* read, not uploads. */
226-
export async function sync(directory: string): Promise<SyncReport> {
238+
export async function sync(
239+
directory: string,
240+
/** A binding the caller has already verified. Used as given rather than re-read from the shared cache, which another
241+
* process can replace between the caller's check and the sweep; one never approved on this machine is refused here,
242+
* where the memory is actually sent. */
243+
opts: { binding?: CachedBinding } = {},
244+
): Promise<SyncReport> {
227245
const gated = (why: NonNullable<SyncReport["gatedBecause"]>): SyncReport => ({
228246
gated: true,
229247
gatedBecause: why,
@@ -240,9 +258,15 @@ export async function sync(directory: string): Promise<SyncReport> {
240258
// unpinned session keeps the cache-only read, and a pin that cannot be honoured stays gated —
241259
// under its own reason, since "nothing is linked" would misdescribe a workspace that exists —
242260
// rather than falling through to the project's link.
243-
const pinned = await resolvePinnedBindingForRouting(directory).catch(() => ({ status: "unknown" as const }))
244-
if (pinned && pinned.status !== "bound") return gated("pin-unresolved")
245-
const binding = pinned ? pinned.binding : await readLocalBinding(directory).catch(() => null)
261+
let binding: CachedBinding | null
262+
if (opts.binding) {
263+
if (opts.binding.adopted) return gated("not-approved")
264+
binding = opts.binding
265+
} else {
266+
const pinned = await resolvePinnedBindingForRouting(directory).catch(() => ({ status: "unknown" as const }))
267+
if (pinned && pinned.status !== "bound") return gated("pin-unresolved")
268+
binding = pinned ? pinned.binding : await readLocalBinding(directory).catch(() => null)
269+
}
246270
if (!binding) return gated("no-binding")
247271

248272
const blocks = await MemoryStore.listAll({ directory }).catch((err) => {
@@ -300,7 +324,7 @@ async function memoryCounts(
300324
directory: string,
301325
binding: CachedBinding | null,
302326
poll: boolean,
303-
): Promise<{ local: number; unsynced: number | null } | null> {
327+
): Promise<{ local: number; unsynced: number | null } | null | "unreadable"> {
304328
if (!MemorySync.isEnabled()) return null
305329
try {
306330
const blocks = await MemoryStore.listAll({ directory })
@@ -317,7 +341,7 @@ async function memoryCounts(
317341
return { local: blocks.length, unsynced: await MemorySync.pendingCount(blocks, binding, { network: false }) }
318342
} catch (err) {
319343
log.warn("could not count local memory for the workspace status", { err: String(err) })
320-
return null
344+
return "unreadable"
321345
}
322346
}
323347

‎packages/opencode/src/altimate/workspace/state.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -916,6 +916,21 @@ export function peekRowUnscoped(directory: string): UnscopedRow | null {
916916
}
917917
}
918918

919+
/** Whether this directory holds the approved (not adopted) row a `recordApprovedBinding` call wrote: `datamateId`
920+
* under `account`, stamped with the `linkedAt` that call passed, so an approval recorded earlier does not count.
921+
* `recordApprovedBinding` returns "account-changed" both before its write and after it (at the memory seed);
922+
* this tells the caller which one happened. */
923+
export function isApprovedRow(directory: string, account: string, datamateId: number, linkedAt: number): boolean {
924+
try {
925+
const cache = readCache()
926+
if (!cache || cache.account !== account) return false
927+
const row = primaryRow(cache, directory)
928+
return row?.datamateId === datamateId && row.linkedAt === linkedAt && !row.adopted
929+
} catch {
930+
return false
931+
}
932+
}
933+
919934
/** The row reads win for a directory: the canonical key, or failing that the
920935
* newest alias. */
921936
function primaryRow(cache: CacheFile, directory: string): CachedBinding | undefined {

0 commit comments

Comments
 (0)