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
58 changes: 54 additions & 4 deletions docs/docs/data-engineering/guides/using-with-claude-code.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
title: "Using Altimate with Claude Code"
description: "Run Altimate's data engineering tools alongside Claude Code via the /altimate command or MCP server integration."
description: "Hand data engineering work from Claude Code to Altimate Code with the Altimate Code plugin, and use an Altimate workspace's skills, knowledge and memory from Claude Code."
---

# Using altimate with Claude Code
Expand Down Expand Up @@ -80,9 +80,29 @@ altimate:

## Setup

1. Install altimate globally: `npm install -g altimate-code`
2. Configure warehouse connections in your project
3. Run `/configure-claude` inside altimate to set up the integration:
### The Altimate Code plugin (recommended)

1. Install Altimate Code (Node.js 20 or later): `npm install -g altimate-code`
2. Install the plugin from Anthropic's plugin directory:

```bash
claude plugin install altimate-code

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: Register the official marketplace before this shell install and qualify the plugin name; on a clean machine, the marketplace is not registered until an interactive session starts.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At docs/docs/data-engineering/guides/using-with-claude-code.md, line 89:

<comment>Register the official marketplace before this shell install and qualify the plugin name; on a clean machine, the marketplace is not registered until an interactive session starts.</comment>

<file context>
@@ -80,9 +80,29 @@ altimate:
+2. Install the plugin from Anthropic's plugin directory:
+
+    ```bash
+    claude plugin install altimate-code
+    ```
+
</file context>

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Not changing this. I checked it on a clean Claude Code 2.1.302 config (CLAUDE_CONFIG_DIR set to an empty folder): claude plugin install altimate-code installs altimate-code@anthropic-plugin-directory from the built-in directory, with no marketplace registration. The suggested altimate-code@claude-plugins-official is the command that fails on current Claude Code ("not found in marketplace").

```
Comment thread
coderabbitai[bot] marked this conversation as resolved.

Or add Altimate's own marketplace and install from it:

```bash
claude plugin marketplace add AltimateAI/altimate-claude-plugin
claude plugin install altimate-code@altimate-claude-plugin
```

3. Start a new Claude Code session in your project. Ask for dbt or warehouse work as usual, or start a request with `/altimate-code:altimate` to hand it over yourself. (Claude Code names plugin commands `<plugin>:<command>`; the bare `/altimate` below comes from `/configure-claude`.)

The plugin runs `altimate-code run` in your project for each task, with the model provider Altimate Code is set up with. If you have not set one up, run `altimate-code` once and choose a provider, or sign in to Altimate (below).

### The `/altimate` command, without the plugin

Run `/configure-claude` inside altimate to write the command:

```bash
altimate
Expand All @@ -92,6 +112,36 @@ altimate

This creates `~/.claude/commands/altimate.md`. You can now use `/altimate` in any Claude Code session.

## Use an Altimate workspace from Claude Code

Claude Code reaches a workspace through Altimate Code: every task the plugin hands over runs in your project, so it gets the workspace the project is linked to — its skills, its knowledge, your team's shared memory and its integrations. Nothing in Claude Code itself needs configuring.

1. **Sign in to Altimate.** In a terminal:

```bash
altimate-code auth login -p altimate-backend --method "Altimate LLM Gateway"
```

It opens your browser; sign in and return to the terminal. (Inside the TUI, `/connect` does the same.) The command also works from Claude Code: type it after `!` in the prompt.

2. **Link the project.** In the project folder:

```bash
altimate-code link
```

Pick the workspace, or create one. The picker needs an interactive terminal, so it does not run from Claude Code's `!` prompt or a script. There, list the workspaces with `altimate-code workspace list` and link with `altimate-code link --workspace <id or name>`, or create one with `altimate-code link --create`. Moving a linked project to a different workspace also needs `--yes`. Claude can run these for you: ask it to link the project to a workspace.

3. **Check it.** `altimate-code workspace status` shows the linked workspace and when its skills last synced.

4. **Ask Claude.** Questions about your team's conventions, best practices or knowledge are answered from the workspace when they reach Altimate Code. Start them with `/altimate-code:altimate` to be sure they do:

```
/altimate-code:altimate what are our naming conventions for staging models?
```

What you teach Altimate Code during these tasks is saved to the workspace's memory and shared with your team in the CLI, the IDE and Studio. If an upload fails, the task ends with a line saying so; `altimate-code workspace sync` sends what is left. Skills added to the workspace sync into `.altimate-code/skill/_workspace/` at the start of the next task.

## When to use which

| Task | Use Claude Code | Use altimate |
Expand Down
5 changes: 4 additions & 1 deletion docs/docs/usage/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ altimate --agent analyst
| `import` | Import session data |
| `session` | Session management |
| `link` | Link this project to an Altimate workspace |
| `workspace` | Show, refresh, sync or unlink this project's workspace from a shell |
| `workspace` | List workspaces, or show, refresh, sync or unlink this project's workspace from a shell |
| `trace` | List and view session traces (recordings of agent sessions) |
| `github` | GitHub integration |
| `pr` | Pull request tools |
Expand All @@ -57,6 +57,7 @@ With workspaces on:
- `altimate-code link` links the current project to a workspace (or creates one), then shows what the workspace provides (its skills, knowledge, integrations and whether memory is on) and a few prompts to start with. The sidebar then names the workspace and shows how many memories are not yet synced and when skills last synced.
- From a shell, script or CI job, without the TUI:
- `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.
- `altimate-code workspace list [--json]` lists the workspaces your account can link to, with their ids, for `link --workspace`.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[NIT] The command table at L43 still describes workspace as "Show, refresh, sync or unlink". Add list there too.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done in c973733: the table row now reads "List workspaces, or show, refresh, sync or unlink…".

- `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.
- `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).
- 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`.
Expand Down Expand Up @@ -189,6 +190,8 @@ altimate --print-logs --log-level DEBUG run "test query"
altimate run --no-trace "quick question"
```

`run` reads piped input to the end, however long the command before the pipe takes. One exception: when `run` already has a message or `--command` and its stdin is a socket, which is what an agent's shell tool or a Node `spawn` hands a child, it waits only 3 seconds for the first byte and then continues without stdin. Set `ALTIMATE_RUN_STDIN_WAIT_MS` to change that wait, or to `0` to always wait. A caller that never writes to or closes stdin, and whose stdin is not a socket (for example a pipe on Windows), should redirect it from an empty source: `< /dev/null`, or `< NUL` in `cmd.exe`.

For CI pipelines and headless automation, see [CI & Automation](ci-headless.md).

## Trace
Expand Down
89 changes: 89 additions & 0 deletions packages/opencode/src/altimate/cli/run-stdin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// altimate_change - new file
import { fstatSync } from "fs"

/** How long `run` waits for the first byte on a socket stdin. */
export const STDIN_WAIT_MS = 3000

/** The longest delay `setTimeout` honours; a larger one fires at once. */
const MAX_TIMER_MS = 2_147_483_647

/** `ALTIMATE_RUN_STDIN_WAIT_MS` overrides the wait, for a caller that writes its stdin late; 0 waits for EOF. */
export function stdinWaitMs(env: Record<string, string | undefined> = process.env): number {
const raw = env["ALTIMATE_RUN_STDIN_WAIT_MS"]?.trim()
if (!raw) return STDIN_WAIT_MS
const ms = Number(raw)
if (!Number.isFinite(ms) || ms < 0) return STDIN_WAIT_MS
return Math.min(ms, MAX_TIMER_MS)
}

export interface StdinSource {
/** True when fd 0 is a regular file (`run < prompt.txt`), which always reaches EOF. */
isFile(): boolean
/** True when fd 0 is a socket: what an agent's shell tool or a Node `spawn` hands a child. A shell
* pipeline (`cmd | altimate-code run`) is a FIFO instead. */
isSocket(): boolean
readAll(): Promise<string>
stream(): ReadableStream<Uint8Array>
}

export const bunStdin: StdinSource = {
isFile() {
try {
return fstatSync(0).isFile()
} catch {
return false
}
},
isSocket() {
try {
return fstatSync(0).isSocket()
Comment on lines +37 to +39

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[MINOR] No test covers real socket detection or the bounded wiring

  isSocket() {
    try {
      return fstatSync(0).isSocket()

Every test in run-stdin.test.ts injects a fake StdinSource, so nothing checks that fstatSync(0).isSocket() is true for the stdin a Node spawn (default stdio) actually hands a child, which is the case this fix depends on. The run.ts call site (readRunStdin(undefined, waitMs, message.trim().length > 0 || !!args.command)) is only covered by a source-string match in the release-validation test, so passing the wrong bounded value or swapping arguments would not fail any test.

Suggested fix: add one subprocess test. Spawn bun -e running a small script that imports readRunStdin and prints the result, use child_process.spawn with default stdio: "pipe", never write to or close stdin, set ALTIMATE_RUN_STDIN_WAIT_MS=200, and assert it prints timedOut: true and exits on its own. A second case that writes and ends stdin should assert the text comes through.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Added test/cli/run/run-stdin-process.test.ts in f6ea10b. It runs readRunStdin in a child process on a real stdin:

  • an idle child_process.spawn socket, with ALTIMATE_RUN_STDIN_WAIT_MS=200, times out and the child exits on its own;
  • a socket that is written and ended comes through;
  • with bounded false, a write after 600ms comes through;
  • a sh pipeline whose first byte arrives after the wait is read in full.

The bounded value at the run.ts call site is still checked only through the source test, which now confirms the read sits inside the guard's block. I checked the three bounded cases on the real CLI earlier (message plus late socket, no message plus late socket, and =0).

} catch {
return false
}
},
readAll: () => Bun.stdin.text(),
stream: () => Bun.stdin.stream(),
}

/**
* Read text piped into `altimate-code run`, without waiting on a stdin that never sends anything.
*
* Agents that shell out to `run` (Claude Code's Bash tool, a Node `spawn` with default stdio) hand
* it a socket they never write to or close. Reading that to EOF blocks forever, so a delegated task
* hangs before it starts. A socket is read only if its first chunk arrives within `waitMs`, and then
* to EOF. Anything else (a file, or a shell pipeline whose producer may take minutes, like
* `dbt build | altimate-code run`) is read to EOF as before. So is a socket when `bounded` is false
* (the caller passed nothing else to run, so stdin is the prompt) or `waitMs` is 0.
*/
export async function readRunStdin(
source: StdinSource = bunStdin,
waitMs: number = STDIN_WAIT_MS,
bounded = true,
): Promise<{ text: string; timedOut: boolean }> {
if (!bounded || waitMs <= 0 || source.isFile() || !source.isSocket())
return { text: await source.readAll(), timedOut: false }
const reader = source.stream().getReader()
let timer: ReturnType<typeof setTimeout> | undefined
try {
const first = await Promise.race([

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[MAJOR] The 3s first-byte cutoff drops stdin from slow shell pipelines (consensus: Claude, GPT 5.3 Codex; DeepSeek V4 Pro noted it as a ripple effect)

Any stdin that is not a regular file is abandoned if its first chunk takes more than STDIN_WAIT_MS. So dbt build 2>&1 | altimate-code run "fix the failures", psql … | altimate-code run … and curl … | altimate-code run … used to read to EOF and now run without the piped context. The only sign is one line on stderr, and cancelling the reader can send the producer SIGPIPE.

The stalled stdin this PR targets comes from a Node spawn with default stdio, which on Unix is a socketpair (S_ISSOCK). A shell pipeline is a FIFO (S_ISFIFO), so the two can be told apart.

Suggested fix (options can be combined):

  • Apply the timeout only to sockets:
    isSocket() { try { return fstatSync(0).isSocket() } catch { return false } }
    // in readRunStdin: if (!source.isSocket()) read to EOF as before
  • Add an override, e.g. ALTIMATE_RUN_STDIN_WAIT_MS (0 = wait for EOF), or a --stdin-wait flag.
  • Document the rule in docs/docs/usage/cli.md.

There is no test for this case: the "slow pipe" test only delays the chunks after the first one. Please add one where the first chunk arrives after waitMs.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 6551112. The first-byte wait now applies only when stdin is a socket (fstatSync(0).isSocket()), which is what Claude Code's shell and a Node spawn hand a child. A FIFO is read to EOF as before. I checked it against the real binary: (sleep 5; echo MARKER) | altimate-code run … answered with the marker, and an idle socket still gave up after 3s. A test covers the pipeline case.

reader.read(),
new Promise<"timeout">((resolve) => {
timer = setTimeout(() => resolve("timeout"), waitMs)
}),
])
if (first === "timeout") return { text: "", timedOut: true }
if (first.done) return { text: "", timedOut: false }
const decoder = new TextDecoder()
let text = decoder.decode(first.value, { stream: true })
while (true) {

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: The stdin wait is only bounded before the first chunk; a pipe that writes one chunk and stays open still hangs in the unbounded read-to-EOF loop. Bound idle reads after data arrives or explicitly cancel the stream when the input becomes idle.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At packages/opencode/src/altimate/cli/run-stdin.ts, line 51:

<comment>The stdin wait is only bounded before the first chunk; a pipe that writes one chunk and stays open still hangs in the unbounded read-to-EOF loop. Bound idle reads after data arrives or explicitly cancel the stream when the input becomes idle.</comment>

<file context>
@@ -0,0 +1,62 @@
+    if (first.done) return { text: "", timedOut: false }
+    const decoder = new TextDecoder()
+    let text = decoder.decode(first.value, { stream: true })
+    while (true) {
+      const next = await reader.read()
+      if (next.done) break
</file context>

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Not changing this. Once data has arrived, the input is being piped on purpose, and reading to EOF is what cat file | altimate-code run and every other filter does. An idle timeout after the first chunk would silently cut off a slow producer. The hang being fixed here is an open stdin that never sends anything, which Claude Code's shell causes; that is bounded.

const next = await reader.read()
if (next.done) break
text += decoder.decode(next.value, { stream: true })
}
return { text: text + decoder.decode(), timedOut: false }
} finally {
if (timer) clearTimeout(timer)
// Releases the stream so an idle stdin does not keep the process alive after the run.
reader.cancel().catch(() => {})
}
}
55 changes: 55 additions & 0 deletions packages/opencode/src/altimate/cli/unsent-memory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// altimate_change - new file
import * as Manage from "@/altimate/workspace/manage"
import { lastMirrorTarget, type MirrorTarget } from "@/altimate/workspace/memory-sync"

/** How long `run`'s exit waits for the count. It is read from local files, so this only bounds a stalled disk. */
export const UNSENT_CHECK_MS = 5000

/**
* What `run` prints at exit about memory on this machine that the workspace has not received, or null
* when there is nothing to say. `unsynced` is `workspace status`'s count for the project, not only this
* run's: an upload that failed, was deferred or declined by the service, or was still in flight when the
* exit flush gave up is pending there; a block deleted since is not. A workspace with memory turned off
* keeps its blocks here by design and is not reported.
*/
export function unsentMemoryNotice(unsynced: number | null): string | null {
if (unsynced === null)
return "Could not confirm that the memory saved here reached the workspace. Run `altimate-code workspace status` to check."
if (unsynced === 0) return null
return (
`${unsynced === 1 ? "1 memory on this machine is" : `${unsynced} memories on this machine are`} not in the workspace yet. ` +
`Run \`altimate-code workspace sync\` to send ${unsynced === 1 ? "it" : "them"}.`
)
}

export interface UnsentMemoryDeps {
target(): MirrorTarget | undefined
status(directory: string, binding: MirrorTarget["binding"]): Promise<Manage.StatusReport>
}

const defaultDeps: UnsentMemoryDeps = {
target: lastMirrorTarget,
// Passing the binding the upload used keeps `Manage.status` off the network.
status: (directory, binding) => Manage.status(directory, { binding }),
}

/** Counted only when this process tried to upload memory to a linked workspace, so other runs pay nothing.
* A count that cannot be read, or not within `timeoutMs`, is reported as unknown. */
export async function unsentMemoryNoticeAfterRun(
fallbackDirectory: string,
timeoutMs: number = UNSENT_CHECK_MS,
deps: UnsentMemoryDeps = defaultDeps,
): Promise<string | null> {
const target = deps.target()
if (!target) return null
let timer: ReturnType<typeof setTimeout> | undefined
const report = await Promise.race([
deps.status(target.directory ?? fallbackDirectory, target.binding).catch(() => null),
new Promise<null>((resolve) => {
timer = setTimeout(() => resolve(null), timeoutMs)
}),
]).finally(() => clearTimeout(timer))
if (!report || report.memoryUnreadable) return unsentMemoryNotice(null)
if (!report.binding || !report.memory) return null

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[MINOR] This is the other half of the comment on memory-sync.ts:899. With memory turned off in the workspace, report.memory.unsynced is 0, so we return null here. But the block did stay on this machine, which the doc comment above ("declined") says this function covers. Either report it as local-only, without suggesting a workspace sync that can't upload it, or narrow the doc comment.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Narrowed the doc comment in c973733: "declined" now means declined by the service, and a workspace with memory turned off is named as not reported, since the block stays local by design.

return unsentMemoryNotice(report.memory.unsynced)
}
13 changes: 13 additions & 0 deletions packages/opencode/src/altimate/workspace/empty-record-path.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// altimate_change - new file
import path from "path"
import { createHash } from "node:crypto"
import { Global } from "@/global"

/** Where a sync records that this project's workspace has no skills. Keyed by the resolved
* directory, so the TUI thread and the prompt worker agree on one file. Under the app's state
* directory, so nothing is written into the user's repository. Kept out of `skill-sync` so the
* per-turn gate in `session/prompt` can check it without loading that module. */
export function emptyRecordPath(canon: string): string {
const id = createHash("sha256").update(canon).digest("hex").slice(0, 32)
return path.join(Global.Path.state, "altimate-workspace-empty", id)
}
32 changes: 31 additions & 1 deletion packages/opencode/src/altimate/workspace/memory-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -907,6 +907,25 @@ function serialize<T>(scope: "global" | "project", blockId: string, op: () => Pr
* `skill-sync.flushPendingSyncs` holds it for a cold skill sync. */
const mirrorsInFlight = syncState.mirrorsInFlight

/** The linked workspace this process last tried to upload a block to, and the directory it was bound from.
* A one-shot `run` reads it at exit and, only then, counts the memory still not in the workspace
* (`Manage.status`, given this binding so the count stays local): nothing in `run` retries an upload, so
* without the check a block left behind stays on this machine unannounced. Not set when the workspace has
* memory turned off, since that block stays here by design. Process-global for the same two-module-graph
* reason as `syncState`. */
const mirrorRequests: { last?: MirrorTarget } = ((globalThis as unknown as Record<symbol, { last?: MirrorTarget } | undefined>)[
Symbol.for("altimate.memory-sync.mirror-requests")
] ??= {})

export interface MirrorTarget {
directory: string | undefined
binding: CachedBinding
}

export function lastMirrorTarget(): MirrorTarget | undefined {
return mirrorRequests.last
}

/** Hold a task in `mirrorsInFlight` for its lifetime. */
async function tracked(task: Promise<void>): Promise<void> {
mirrorsInFlight.add(task)
Expand Down Expand Up @@ -953,7 +972,18 @@ export async function mirrorBlock(block: MemoryBlock, directory?: string): Promi
// binding governs only whether we upload at all.
const binding = await currentBinding(directory)
if (!binding) return
if (!(await memoryEnabled(binding))) return
// Recorded before the memory-setting lookup, which can wait on the service: an exit while it is pending
// leaves this block unsent too. Taken back only on a confirmed "disabled", and only while it is still
// this call's record: other blocks mirror concurrently and may have recorded since.
const previous = mirrorRequests.last
const recorded: MirrorTarget = { directory, binding }
mirrorRequests.last = recorded
const memory = await memoryStatus(binding)
if (memory === "disabled") {
if (mirrorRequests.last === recorded) mirrorRequests.last = previous
return
}
if (memory !== "enabled") return
await push(block, binding, undefined, directory)
})
return tracked(task)
Expand Down
17 changes: 5 additions & 12 deletions packages/opencode/src/altimate/workspace/skill-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,9 @@
// detection is per-skill ``updated_at`` and the only integrity check available
// is byte length.
import fs from "fs/promises"
import { createHash } from "node:crypto"
import { statSync } from "node:fs"
import path from "path"
import { Global } from "@/global"
import { emptyRecordPath } from "./empty-record-path"
import { Flag as CoreFlag } from "@opencode-ai/core/flag/flag"
import { Log } from "@/altimate/util/log"
import { AltimateApi } from "@/altimate/api/client"
Expand Down Expand Up @@ -655,16 +654,10 @@ export async function recentlySynced(directory: string): Promise<boolean> {
/** Which account each project's snapshot was last fetched for. */
const syncedFor = store.syncedFor

/** Where the record lives that a project's workspace had no custom skills at the last sync
* (account key + workspace id). On disk rather than on `globalThis`, which is per thread: an
* unlink in the TUI thread has to clear what the prompt worker's sync recorded, and the other way
* round. Under the app's state directory, so nothing is written into the user's repository. */
function emptyRecordPath(canon: string): string {
const id = createHash("sha256").update(canon).digest("hex").slice(0, 32)
return path.join(Global.Path.state, "altimate-workspace-empty", id)
}

/** The record as written: `<key>\n<write id>`. The write id tells one sync's record from another
/** The record as written: `<key>\n<write id>`, at `emptyRecordPath` (./empty-record-path), saying the
* project's workspace had no custom skills at the last sync (account key + workspace id). On disk rather
* than on `globalThis`, which is per thread: an unlink in the TUI thread has to clear what the prompt
* worker's sync recorded, and the other way round. The write id tells one sync's record from another
* sync's record for the same key, so a sync only ever withdraws the record it wrote itself. */
async function readEmptyRecordRaw(canon: string): Promise<string | null> {
try {
Expand Down
Loading
Loading