Skip to content
3 changes: 1 addition & 2 deletions packages/core/src/id/id.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,7 @@ export function create(prefix: string, direction: "descending" | "ascending", ti
export function timestamp(id: string): number {
const prefix = id.split("_")[0]
const hex = id.slice(prefix.length + 1, prefix.length + 13)
const encoded = BigInt("0x" + hex)
return Number(encoded / BigInt(0x1000))
return Number(BigInt("0x" + hex))
}

export * as Identifier from "./id"
44 changes: 36 additions & 8 deletions packages/core/src/plugin/command/workflow-routing.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,17 +68,45 @@ DAG; for a verdict, the matching audit is primary. Do not concatenate two comple

Read the selected reference, retarget its objective and instructions, and
remove phases current evidence already covers. Start its saved `spec_path`
directly only when target and acceptance evidence match. If none fits, load
`guide(topic="blocks")` and compose a task-local YAML. Load
directly only when target and acceptance evidence match. If none fits, compose
a task-local graph. Load `guide(topic="blocks")` for block contracts and
`guide(topic="patterns")` only when domains overlap. Use low-level nodes only
for fields blocks cannot express.

Write the graph to YAML and validate that `spec_path` before start. Fix every
diagnostic in the same file and revalidate; validation creates no workflow. A
successful start returns the exact workflow ID. The parent owns the graph,
controls, and final report; children own bounded work. End after start and let
the workflow wake the parent. Do not poll merely to wait or claim an unstarted
graph is running.
Prefer `workflow(action="draft")` over hand-writing YAML: pass the structured
`config` (same fields as the YAML below) and the tool renders and validates the
spec file, returning the `spec_path` to start. Field-name drift is impossible
because the parameter schema rejects unknown fields. Hand-write YAML only for
features draft does not carry (admission, custom bindings). The exact start
shape, for that fallback and for reading draft output:

```yaml
title: Implement session recovery
config:
name: implement-session-recovery
objective: Implement session recovery with focused tests and review.
blocks:
- id: map
kind: explore
instruction: Locate the ownership and persistence seams.
- id: coding
kind: coding
depends_on: [map]
- id: review
kind: review
depends_on: [coding]
```

Top level is `title`/`mode`/`admission` (optional) and `config` (required);
`objective` lives INSIDE `config`; every block field is one of `id` (required),
`kind` (required), `depends_on`, `instruction`, `worker_type`, `required`,
`report_to_parent` — never `worker`, `prompt`, or `agent`.

Validate that `spec_path` before start. Fix every diagnostic in the same file
and revalidate; validation creates no workflow. A successful start returns the
exact workflow ID. The parent owns the graph, controls, and final report;
children own bounded work. End after start and let the workflow wake the
parent. Do not poll merely to wait or claim an unstarted graph is running.

## Progressive guidance

Expand Down
4 changes: 3 additions & 1 deletion packages/core/test/plugin/command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,9 @@ describe("CommandPlugin.Plugin", () => {

it.effect("keeps always-on guidance small and loads detailed topics progressively", () =>
Effect.sync(() => {
expect(Buffer.byteLength(CommandPlugin.WorkflowContent)).toBeLessThan(5_000)
// Budget admits the inline start-spec example (one-hop field reference
// for hand-written YAML) while keeping per-action manuals progressive.
expect(Buffer.byteLength(CommandPlugin.WorkflowContent)).toBeLessThan(6_500)
expect(CommandPlugin.WorkflowContent).toContain("project-level source or test changes")
expect(CommandPlugin.WorkflowContent).toMatch(/even one project\s+file/)
expect(CommandPlugin.WorkflowContent).toMatch(/isolated utility\s+scripts/)
Expand Down
28 changes: 27 additions & 1 deletion packages/opencode/src/dag/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,32 @@ function collectLeafIssues(issue: unknown, path: readonly string[], out: LeafIss
if (message) out.push({ path: nextPath.join("") || "$", message })
}

// High-frequency authoring drift: fields the model reaches for from
// neighboring vocabularies, mapped to the field that exists. The decode leaf
// only carries the tag ("UnexpectedKey"); the offending field name lives in
// the diagnostic path, so both are matched.
const FIELD_DRIFT_HINTS: Record<string, string> = {
worker: "worker_type",
workers: "worker_type",
agent: "worker_type",
prompt: "instruction",
task: "instruction",
objective: "config.objective",
graph: "config",
spec: "config",
nodes: "blocks (or vice versa — exactly one graph source)",
blocks: "nodes (or vice versa — exactly one graph source)",
}

function driftHint(path: string, message: string) {
for (const [wrong, right] of Object.entries(FIELD_DRIFT_HINTS)) {
if (message.includes(`"${wrong}"`) || path.includes(`["${wrong}"]`)) {
return `Did you mean "${right}"? Every block field is one of id, kind, depends_on, instruction, worker_type, required, report_to_parent; objective lives inside config`
}
}
return "Fix the field shape; blocks graphs need name+objective+blocks, nodes graphs need name+nodes"
}

export function schemaDiagnostics(error: unknown, basePath = ""): Diagnostic[] {
const leaves: LeafIssue[] = []
collectLeafIssues(isRecord(error) && error.issue !== undefined ? error.issue : error, basePath ? [basePath] : [], leaves)
Expand All @@ -358,7 +384,7 @@ export function schemaDiagnostics(error: unknown, basePath = ""): Diagnostic[] {
code: DIAGNOSTIC_CODES.schemaInvalid,
path: leaf.path,
message: leaf.message,
hint: "Fix the field shape; blocks graphs need name+objective+blocks, nodes graphs need name+nodes",
hint: driftHint(leaf.path, leaf.message),
}),
),
)
Expand Down
29 changes: 13 additions & 16 deletions packages/opencode/src/id/id.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,14 @@ const prefixes = {

const LENGTH = 26

// State for monotonic ID generation
let lastTimestamp = 0
let counter = 0
// Latch over the raw millisecond value, shared by both directions. The prefix
// is the full 48-bit timestamp with no shift so it only wraps after 2^48 ms
// (~8925 years); without the latch, same-millisecond bursts would collide and
// clock regression would emit out-of-order ids. Historical ids (pre
// 2026-08-14) encoded (ts mod 2^36) << 12 and sort above new ids, so
// lexicographic id comparison across that boundary is invalid by design —
// ordering must always come from time.created.
let lastValue = 0n

export function ascending(prefix: keyof typeof prefixes, given?: string) {
return generateID(prefix, "ascending", given)
Expand Down Expand Up @@ -49,17 +54,10 @@ function randomBase62(length: number): string {
}

export function create(prefix: string, direction: "descending" | "ascending", timestamp?: number): string {
const currentTimestamp = timestamp ?? Date.now()

if (currentTimestamp !== lastTimestamp) {
lastTimestamp = currentTimestamp
counter = 0
}
counter++

let now = BigInt(currentTimestamp) * BigInt(0x1000) + BigInt(counter)

now = direction === "descending" ? ~now : now
const current = BigInt(timestamp ?? Date.now())
const value = current > lastValue ? current : lastValue + 1n
lastValue = value
let now = direction === "descending" ? ~value : value

const timeBytes = Buffer.alloc(6)
for (let i = 0; i < 6; i++) {
Expand All @@ -73,8 +71,7 @@ export function create(prefix: string, direction: "descending" | "ascending", ti
export function timestamp(id: string): number {
const prefix = id.split("_")[0]
const hex = id.slice(prefix.length + 1, prefix.length + 13)
const encoded = BigInt("0x" + hex)
return Number(encoded / BigInt(0x1000))
return Number(BigInt("0x" + hex))
}

export * as Identifier from "./id"
24 changes: 15 additions & 9 deletions packages/opencode/src/session/message-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -579,29 +579,35 @@ export const filterCompactedEffect = Effect.fnUntraced(function* (sessionID: Ses

// filterCompacted reorders messages for model consumption
// ([compaction-user, summary, ...retained tail..., continue-user]), so array
// position is not chronological. Derive each binding by max id (MessageID
// is monotonic via MessageID.ascending) so a pre-compaction overflowing tail
// assistant doesn't get mistaken for the most recent turn. tasks are
// compaction/subtask parts attached to user messages newer than the latest
// finished assistant — i.e. unprocessed work.
// position is not chronological. Derive each binding by max (time.created, id)
// — time.created is the primary order because MessageIDs are not
// lexicographically ordered across the 2026-08 id-scheme boundary (historical
// pre-wrap ids sort above new ids); the id tiebreak only resolves
// same-millisecond bursts. tasks are compaction/subtask parts attached to user
// messages newer than the latest finished assistant — i.e. unprocessed work.
export function latest(msgs: WithParts[]) {
let user: User | undefined
let assistant: Assistant | undefined
let finished: Assistant | undefined
for (const msg of msgs) {
const info = msg.info
if (info.role === "user" && (!user || info.id > user.id)) user = info
if (info.role === "assistant" && (!assistant || info.id > assistant.id)) assistant = info
if (info.role === "assistant" && info.finish && (!finished || info.id > finished.id)) finished = info
if (info.role === "user" && (!user || before(user, info))) user = info
if (info.role === "assistant" && (!assistant || before(assistant, info))) assistant = info
if (info.role === "assistant" && info.finish && (!finished || before(finished, info))) finished = info
}
const tasks = msgs.flatMap((m) =>
finished && m.info.id <= finished.id
finished && !before(finished, m.info)
? []
: m.parts.filter((p): p is CompactionPart | SubtaskPart => p.type === "compaction" || p.type === "subtask"),
)
return { user, assistant, finished, tasks }
}

export function before(a: { id: string; time: { created: number } }, b: { id: string; time: { created: number } }) {
if (a.time.created !== b.time.created) return a.time.created < b.time.created
return a.id < b.id
}

export function fromError(
e: unknown,
ctx: { providerID: ProviderV2.ID; aborted?: boolean },
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1510,7 +1510,7 @@ export const layer = Layer.effect(
(lastAssistant?.finish &&
!["tool-calls"].includes(lastAssistant.finish) &&
!hasToolCalls &&
lastUser.id < lastAssistant.id)
MessageV2.before(lastUser, lastAssistant))
) {
const orphan = lastAssistantMsg?.parts.find(
(part): part is SessionV1.ToolPart => part.type === "tool" && isOrphanedInterruptedTool(part),
Expand Down
10 changes: 7 additions & 3 deletions packages/opencode/src/session/revert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,8 @@ export const layer = Layer.effect(
if (session.revert?.snapshot) yield* snap.restore(session.revert.snapshot)
yield* snap.revert(patches)
if (rev.snapshot) rev.diff = yield* snap.diff(rev.snapshot)
const range = all.filter((msg) => msg.info.id >= rev.messageID)
const revMessage = all.find((msg) => msg.info.id === rev.messageID)
const range = revMessage ? all.filter((msg) => !MessageV2.before(msg.info, revMessage.info)) : []
const diffs = yield* summary.computeDiff({ messages: range })
yield* storage.write(["session_diff", input.sessionID], diffs).pipe(Effect.ignore)
yield* events.publish(Session.Event.Diff, { sessionID: input.sessionID, diff: diffs })
Expand Down Expand Up @@ -102,11 +103,14 @@ export const layer = Layer.effect(
const sessionID = session.id
const msgs = yield* sessions.messages({ sessionID }).pipe(Effect.orDie)
const messageID = session.revert.messageID
const revMessage = msgs.find((msg) => msg.info.id === messageID)
if (!revMessage) return
const revInfo = revMessage.info
const remove = [] as SessionV1.WithParts[]
let target: SessionV1.WithParts | undefined
for (const msg of msgs) {
if (msg.info.id < messageID) continue
if (msg.info.id > messageID) {
if (MessageV2.before(msg.info, revInfo)) continue
if (MessageV2.before(revInfo, msg.info)) {
remove.push(msg)
continue
}
Expand Down
3 changes: 2 additions & 1 deletion packages/opencode/src/session/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -796,6 +796,7 @@ export const layer: Layer.Layer<
metadata: structuredClone(original.metadata),
})
const msgs = yield* messages({ sessionID: input.sessionID })
const cutoff = input.messageID ? msgs.find((msg) => msg.info.id === input.messageID) : undefined
const idMap = new Map<string, MessageID>()

// Every updateMessage/updatePart publishes a durable event, and each
Expand All @@ -811,7 +812,7 @@ export const layer: Layer.Layer<
() =>
Effect.gen(function* () {
for (const msg of msgs) {
if (input.messageID && msg.info.id >= input.messageID) break
if (cutoff && !MessageV2.before(msg.info, cutoff.info)) break
const newID = MessageID.ascending()
idMap.set(msg.info.id, newID)

Expand Down
13 changes: 7 additions & 6 deletions packages/opencode/src/tool/truncate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import type { Agent } from "../agent/agent"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { evaluate } from "@/permission/evaluate"
import { Config } from "@/config/config"
import { Identifier } from "../id/id"
import { ToolID } from "./schema"
import { TRUNCATION_DIR } from "./truncation-dir"

Expand Down Expand Up @@ -52,16 +51,18 @@ export const layer = Layer.effect(
const fs = yield* FSUtil.Service

const cleanup = Effect.fn("Truncate.cleanup")(function* () {
const cutoff = Identifier.timestamp(
Identifier.create("tool", "ascending", Date.now() - Duration.toMillis(RETENTION)),
)
const cutoff = Date.now() - Duration.toMillis(RETENTION)
const entries = yield* fs.readDirectory(TRUNCATION_DIR).pipe(
Effect.map((all) => all.filter((name) => name.startsWith("tool_"))),
Effect.catch(() => Effect.succeed([])),
)
for (const entry of entries) {
if (Identifier.timestamp(entry) >= cutoff) continue
yield* fs.remove(path.join(TRUNCATION_DIR, entry)).pipe(Effect.catch(() => Effect.void))
const file = path.join(TRUNCATION_DIR, entry)
const info = yield* fs.stat(file).pipe(Effect.catch(() => Effect.void))
if (!info) continue
const mtime = Option.getOrElse(info.mtime, () => new Date(0)).getTime()
if (mtime >= cutoff) continue
yield* fs.remove(file).pipe(Effect.catch(() => Effect.void))
}
})

Expand Down
Loading
Loading