Skip to content
Merged
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
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,24 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.12.10] - 2026-11-07

**Workspace rules are no longer read from the repository, and a session's title no longer sticks on its placeholder.** The workspace rules state (queued proposals, pending approvals, review notices, dismissed topics and the personal learning choice) moves out of the project folder to a private per-user file, so a branch, merge or pull cannot plant a proposal or an approval that a client would then send with the signed-in person's credential, and team rules reach a session only for the account that pulled them. A session whose first title request fails now retries on the next messages and records the provider's error. Also in this release: a project linked by its path can gain its git remote so team rules can sync, `learn status` says why rules are off, and `tool` commands deliver a large answer whole on a pipe.

**Heads-up for support:**
- **Workspace rules state moves to `$XDG_STATE_HOME/altimate-code/workspace-rules/<hash>.json`** (usually `~/.local/state/…`), keyed by the project folder's real path, so two clones or a moved folder keep separate state. Only projects linked to a workspace are affected. There is no migration from the in-project `.altimate-code/learn/workspace-rules/rules.json`, on purpose: that file is no longer read and can be deleted. Lost with it: proposals queued but never sent, review notices, dismissed topics, the personal learning choice (someone who turned learning off in `/rules` is back on the workspace setting until they set it again), and the record of which usage was already reported, so the first report after upgrading can count the last 30 days again. Until the first sync after upgrading (at session start, then every 5 minutes, or `altimate-code learn sync`) a session has no team rules. The in-project `approved.json` copy is still written for display and recurrence but is never used for delivery.
- **Before the first sync, `learn status` distinguishes** not linked, linked by path only (rules need a git remote), not signed in, learning off, and linked but not yet synced, and the `learn sync` skip message says what to do.
- **A path-linked project that now has a git remote gets it added to the link** when `altimate-code link` or the TUI link picker is run again and the same workspace is picked, or with `link --workspace <id>`, instead of stopping at "Already linked".
- **A session keeps `New session - <time>` only while its title request keeps failing.** The title is retried on each of the first five user messages, always from the first message; a session titled on the first message makes no extra request, and one that already has more than five messages (for example resumed from an older version) is left as it is. Each failure writes `failed to generate title` to the log with the provider's error (`errorType`, `message`, `status`, `attempt`) and sends a `title_generation_failed` telemetry event (provider and model that were asked for, attempt, error class and type, HTTP status; no message text). The worst case for a session whose small model never answers is five attempts of up to three HTTP requests each. A `small_model` the provider cannot serve, or does not list, is a likely cause; the models page now describes it.
- **`tool` commands write the whole answer before returning.** Read stdout while the command runs: a reader that stops taking output for 60 seconds makes the command exit 1 with a message on stderr, as does a write that fails (for example a full disk), where it used to exit 0 with the answer lost. A reader that closes early, such as `| head`, still exits 0.

### Fixed

- **Workspace rules state lives outside the project.** Proposals, approvals and notices found in the working tree are no longer trusted or acted on; the state is a private per-user file, and team rules come from it only for the account that pulled them. (#1669)
- **A project linked by its path can gain its git remote**, and `learn status` says why workspace rules are off (not linked, linked by path only, not signed in, learning off, not yet synced) instead of "not linked". The learn docs say that rules need a git remote. (#1670)
- **`tool` commands no longer lose a big answer on a pipe.** A slow reader used to receive only the first 64 KiB of a larger answer (for example 64 KiB of a 354 KB `tool run --json` result). `tool run` in JSON and text mode, `tool list --json`, `tool context` and `tool schema` now write the whole answer before returning. (#1687)
- **A failed session title is retried on later turns and reported.** The log carries the provider's error instead of "No output generated", a failure before the request (a `small_model` the provider does not list) is reported too, and telemetry gets a `title_generation_failed` event. (#1688)

## [0.12.9] - 2026-11-04

**Claude Code and other coding agents can call altimate-code's data tools directly.** `altimate-code mcp serve` and the new model-free `altimate-code tool` CLI run the same tools without a second model: reads by default, writes only with `--allow-write` (over MCP, `warehouse_add` can save a connection without it). In projects linked to a workspace, what people teach the agent can become a team rule: they confirm it on a card, the workspace owner reviews it, and approved rules reach every teammate's sessions. `dbt_lineage` and `impact_analysis` now return structured lineage, and column impact traces each downstream model's compiled SQL. Also in this release: `auth logout` removes the saved Altimate gateway key, the analyst stays read-only whatever config grants, and the home screen suggests the Claude Code and Codex plugins.
Expand Down
2 changes: 1 addition & 1 deletion docs/docs/configure/models.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ altimate supports models from all configured providers. Use the `model` and `sma

The model format is `provider/model-name`.

`small_model` handles the small background requests that are not part of the conversation, such as the session title and prompt enhancement. When it is not set, Altimate Code picks a cheap model from the same provider (Claude Haiku, Gemini Flash or GPT-5 nano when the provider lists one) and otherwise uses the chat model. A `small_model` the provider cannot serve, or does not list, fails only those requests: chat keeps working, while new sessions keep their `New session - <time>` placeholder title until a retry succeeds. Altimate Code retries the title on each of the first five messages of a session and reports every failure in telemetry.
`small_model` handles the small background requests that are not part of the conversation, such as the session title and prompt enhancement. When it is not set, Altimate Code picks a cheap model from the same provider (Claude Haiku, Gemini Flash or GPT-5 nano when the provider lists one) and otherwise uses the chat model. A `small_model` the provider cannot serve, or does not list, fails only those requests: chat keeps working, while new sessions keep their `New session - <time>` placeholder title until a retry succeeds. Altimate Code retries the title on each of the first five messages of a session and reports every failure in telemetry. A failure shows up as a `failed to generate title` line in the log, with the provider's error, and as a `title_generation_failed` telemetry event naming the model that was asked for; correct or remove the `small_model` value to resolve it.

## Browsing Models

Expand Down
22 changes: 20 additions & 2 deletions packages/opencode/src/cli/cmd/tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -303,18 +303,36 @@ const ToolDaemonCommand = cmd({
*/
function writeAll(text: string) {
const bytes = Buffer.from(text)
let progressAt = performance.now()
for (let at = 0; at < bytes.length; ) {
let wrote = 0
try {
at += writeSync(1, bytes, at)
wrote = writeSync(1, bytes, at)
} catch (error) {
const code = (error as NodeJS.ErrnoException).code
if (code === "EPIPE") return // The reader left, so nobody is waiting for the rest.
if (code !== "EAGAIN") throw error
Bun.sleepSync(2)
}
if (wrote > 0) {
at += wrote
progressAt = performance.now()
continue
}
// Nothing was taken (EAGAIN, or a zero-byte write). A reader that stopped draining the pipe (a
// stopped pager, a wedged log shipper) would otherwise keep this process spinning forever; after
// a minute without progress on a monotonic clock, fail.
if (performance.now() - progressAt > WRITE_STALL_MS) {
throw new Error(
`stdout stopped accepting output for ${WRITE_STALL_MS / 1000} s; ${bytes.length - at} bytes of the answer were not written`,
)
}
Bun.sleepSync(2)
}
}

/** How long `writeAll` waits for a reader that has stopped taking output before it gives up. */
const WRITE_STALL_MS = 60_000

export const ToolCommand = cmd({
command: "tool",
describe: "run altimate-code data tools directly, without a model",
Expand Down
19 changes: 15 additions & 4 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1254,12 +1254,13 @@ export namespace SessionPrompt {

step++
if (step === 1)
ensureTitle({
// altimate_change — fire-and-forget, but never an unhandled rejection
void ensureTitle({
session,
modelID: lastUser.model.modelID,
providerID: lastUser.model.providerID,
history: msgs,
})
}).catch((error) => log.error("failed to generate title", { error: String(error) }))

const model = await Provider.getModel(lastUser.model.providerID, lastUser.model.modelID).catch((e) => {
if (Provider.ModelNotFoundError.isInstance(e)) {
Expand Down Expand Up @@ -4948,7 +4949,17 @@ NOTE: At any point in time through this workflow you should feel free to ask the
let text = ""
let failure: unknown
let model: Provider.Model | undefined
// The model the request is for, so a failed lookup is reported against the model that was asked
// for (the title agent's own, or `small_model`), not against the chat model.
let requested: { providerID: string; modelID: string } | undefined
try {
requested = await iife(async () => {
if (agent.model) return { providerID: String(agent.model.providerID), modelID: String(agent.model.modelID) }
const small = (await Config.get()).small_model
if (!small) return undefined
const parsed = Provider.parseModel(small)
return { providerID: String(parsed.providerID), modelID: String(parsed.modelID) }
})
model = await iife(async () => {
// agent.model carries core ProviderV2.ID/ModelV2.ID brands; re-brand to fork ProviderID/ModelID (identity at runtime)
if (agent.model)
Expand Down Expand Up @@ -5002,8 +5013,8 @@ NOTE: At any point in time through this workflow you should feel free to ask the
if (failure !== undefined || !cleaned) {
const err = failure ?? new Error(text.trim() ? "the model returned no title line" : "the model returned no text")
const described = describeTitleError(err)
const providerID = model?.providerID ?? input.providerID
const modelID = model?.id ?? input.modelID
const providerID = model?.providerID ?? requested?.providerID ?? input.providerID
const modelID = model?.id ?? requested?.modelID ?? input.modelID
// Name, message and status only: a provider error object can carry the request body.
log.error("failed to generate title", {
errorType: described.name,
Expand Down
38 changes: 37 additions & 1 deletion packages/opencode/test/session/title-retry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,8 @@ it.live(
failed = events
}
expect(failed).toHaveLength(1)
expect(failed[0]).toMatchObject({ provider_id: "test", model_id: "test-model", attempt: 1 })
// The report names the model that was asked for, not the chat model it fell back from.
expect(failed[0]).toMatchObject({ provider_id: "test", model_id: "does-not-exist", attempt: 1 })
expect(failed[0].error_type).toBe("ProviderModelNotFoundError")
expect(failed[0].http_status).toBeUndefined()
const titleHits = (yield* llm.hits).filter((hit) => JSON.stringify(hit.body).includes(TITLE_MARKER))
Expand Down Expand Up @@ -335,3 +336,38 @@ it.live(
),
{ timeout: 60_000 },
)

it.live(
"a name the user gives the session while the title request runs is kept",
() =>
provideTmpdirServerLegacy(
Effect.fnUntraced(function* ({ llm }) {
const { events } = yield* recordTitleFailures
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({
permission: [{ permission: "*", pattern: "*", action: "allow" }],
})

const gate = Promise.withResolvers<void>()
yield* llm.title(reply().wait(gate.promise).text("Generated title").stop())
yield* llm.text("first answer")
yield* prompt.prompt({
sessionID: session.id,
agent: "build",
parts: [{ type: "text", text: "first message" }],
})
yield* llm.wait(2)
// The user names the session while the title answer is still held.
yield* sessions.setTitle({ sessionID: session.id, title: "My own name" })
gate.resolve()

// The generated answer arrives and is discarded: only a placeholder is ever replaced.
for (let i = 0; i < 25; i++) yield* Effect.sleep("20 millis")

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: This regression test can pass before the title request finishes: llm.wait(2) observes request arrival, and the fixed 500 ms sleep does not prove the released response was processed. Wait for a response or session-update completion signal before asserting, otherwise a delayed or stuck title stream leaves this behavior untested.

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/test/session/title-retry.test.ts, line 366:

<comment>This regression test can pass before the title request finishes: `llm.wait(2)` observes request arrival, and the fixed 500 ms sleep does not prove the released response was processed. Wait for a response or session-update completion signal before asserting, otherwise a delayed or stuck title stream leaves this behavior untested.</comment>

<file context>
@@ -335,3 +336,38 @@ it.live(
+        gate.resolve()
+
+        // The generated answer arrives and is discarded: only a placeholder is ever replaced.
+        for (let i = 0; i < 25; i++) yield* Effect.sleep("20 millis")
+        expect((yield* sessions.get(session.id)).title).toBe("My own name")
+        expect(events).toHaveLength(0)
</file context>

expect((yield* sessions.get(session.id)).title).toBe("My own name")
expect(events).toHaveLength(0)
}),
{ git: true, config: providerCfg },
),
{ timeout: 60_000 },
)
Loading
Loading