Repository navigation
fix: Claude Code plugin path to workspaces — headless run hang, first-run skills, unsent memory
#1552
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
fix: Claude Code plugin path to workspaces — headless run hang, first-run skills, unsent memory
#1552
Changes from all commits
3cf4517
885854d
dfa62c1
63f7334
6551112
882d35b
2501582
c973733
533cfe8
f6ea10b
2e53d46
88b5508
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | | ||
|
|
@@ -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`. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [NIT] The command table at L43 still describes
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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`. | ||
|
|
@@ -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 | ||
|
|
||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [MINOR] No test covers real socket detection or the isSocket() {
try {
return fstatSync(0).isSocket()Every test in Suggested fix: add one subprocess test. Spawn
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Added
The |
||
| } 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([ | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 The stalled stdin this PR targets comes from a Node Suggested fix (options can be combined):
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
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 ( |
||
| 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) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| 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(() => {}) | ||
| } | ||
| } | ||
| 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 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [MINOR] This is the other half of the comment on
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) | ||
| } | ||
| 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) | ||
| } |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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_DIRset to an empty folder):claude plugin install altimate-codeinstallsaltimate-code@anthropic-plugin-directoryfrom the built-in directory, with no marketplace registration. The suggestedaltimate-code@claude-plugins-officialis the command that fails on current Claude Code ("not found in marketplace").