Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
89d796b
chore: record delivery binding for replan-topology-refresh
LeXwDeX Aug 31, 2026
b0516b0
chore: record delivery binding for replan-topology-refresh
LeXwDeX Aug 31, 2026
6914c1b
feat(dag): refresh nodes after topology replans
LeXwDeX Aug 31, 2026
29e876a
chore(specgit): restore dev check names
LeXwDeX Aug 31, 2026
ab20499
Merge pull request #469 from LeXwDeX/feat/468-replan-topology-refresh
LeXwDeX Aug 31, 2026
94c52ba
chore: record delivery binding for release-v1-0-37-notes
LeXwDeX Aug 31, 2026
dd58502
chore: record delivery binding for release-v1-0-37-notes
LeXwDeX Aug 31, 2026
909ad73
docs(release): add v1.0.37 notes
LeXwDeX Aug 31, 2026
95ec984
Merge pull request #471 from LeXwDeX/docs/470-release-v1-0-37-notes
LeXwDeX Aug 31, 2026
b656e01
chore: record delivery binding for sync-main-to-dev-before-repair-wave
LeXwDeX Sep 1, 2026
810238f
chore: record delivery binding for sync-main-to-dev-before-repair-wave
LeXwDeX Sep 1, 2026
957163d
chore: rebind delivery record to chore/476 branch and PR #479
LeXwDeX Sep 1, 2026
f14b4d0
chore: merge origin/dev into chore/476-sync-main-to-dev-before-repair…
LeXwDeX Sep 1, 2026
cb779ee
Merge pull request #479 from LeXwDeX/chore/476-sync-main-to-dev-befor…
LeXwDeX Sep 1, 2026
db8b92d
chore: record delivery binding for sdk-openapi-race
LeXwDeX Sep 1, 2026
91afde0
chore: record delivery binding for sdk-openapi-race
LeXwDeX Sep 1, 2026
9cc2551
chore: record delivery binding for sdk-openapi-race
LeXwDeX Sep 1, 2026
b5b29f4
fix(sdk): give the generated spec and codegen output per-run ownership
LeXwDeX Sep 1, 2026
fe70e27
chore: merge origin/dev, keep active sdk-openapi-race binding record
LeXwDeX Sep 1, 2026
c9f6b9f
Merge pull request #485 from LeXwDeX/fix/475-sdk-openapi-race
LeXwDeX Sep 1, 2026
4cb3fef
chore: record delivery binding for stabilize-dirty-worktree
LeXwDeX Sep 1, 2026
3e23ac1
chore: record delivery binding for stabilize-dirty-worktree
LeXwDeX Sep 1, 2026
78ae75c
chore: record delivery binding for stabilize-dirty-worktree
LeXwDeX Sep 1, 2026
a738ee6
fix(core): classify dirty worktree removal via porcelain state
LeXwDeX Sep 1, 2026
0697f20
Merge pull request #482 from LeXwDeX/fix/474-stabilize-dirty-worktree
LeXwDeX Sep 1, 2026
3a45cb1
chore: record delivery binding for git-head-watcher
LeXwDeX Sep 1, 2026
7b3c725
chore: record delivery binding for git-head-watcher
LeXwDeX Sep 1, 2026
31d37ba
chore: record delivery binding for git-head-watcher
LeXwDeX Sep 1, 2026
a6d1e0d
test(core): normalize .git/HEAD watcher events on macOS
LeXwDeX Sep 1, 2026
dacc359
Merge pull request #486 from LeXwDeX/test/git-head-watcher
LeXwDeX Sep 1, 2026
0c46896
fix(test): bound httpapi exerciser teardown and force process exit (#…
LeXwDeX Sep 1, 2026
7a46a41
chore(test): bind delivery record to PR #487
LeXwDeX Sep 1, 2026
616523f
Merge pull request #487 from LeXwDeX/feat/472-issue472
LeXwDeX Sep 1, 2026
100f9d6
docs: remove legacy Claude and OpenSpec repository artifacts
LeXwDeX Sep 1, 2026
ab139bf
chore: record delivery binding for 477-remove-legacy-artifacts
LeXwDeX Sep 1, 2026
816bc81
chore: record delivery binding for 477-remove-legacy-artifacts
LeXwDeX Sep 1, 2026
6e39552
Merge pull request #480 from LeXwDeX/docs/477-remove-legacy-artifacts
LeXwDeX Sep 1, 2026
f3c2d4f
chore: record delivery binding for shell-silence-guard
LeXwDeX Sep 1, 2026
eaee564
feat(opencode): add warn-only shell silence guard
LeXwDeX Sep 1, 2026
bcb2500
chore: bind pull request 491 to delivery shell-silence-guard
LeXwDeX Sep 1, 2026
10d7e0d
chore: merge origin/dev into shell-silence-guard delivery
LeXwDeX Sep 1, 2026
e1a07b5
fix(opencode): refresh bash wire-shape snapshot for expectedSilent
LeXwDeX Sep 1, 2026
1b3347a
chore: normalize shell silence binding
LeXwDeX Sep 1, 2026
06c9779
Merge pull request #491 from LeXwDeX/feat/433-shell-silence-guard
LeXwDeX Sep 1, 2026
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
44 changes: 44 additions & 0 deletions .github/releases/v1.0.37.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
## opencode {VERSION}

{Prerelease/Stable} release from `{branch}` branch. DAG replan topology refresh: equal-count replans now surface a `graphRev` revision that makes the TUI re-fetch superseded node graphs exactly once instead of rendering stale topology.

---

### 🎯 Features

- **DAG replan topology refresh, #469**: an equal-count replan (node count and status distribution unchanged) previously never triggered a TUI node re-fetch, so the inspector and an expanded sidebar kept rendering superseded topology until the next unrelated state change. The existing `workflow.graph_rev` counter is now exposed end to end as `graphRev` (core summary, schema contract, HttpAPI response, regenerated SDK types) and folded into the inspector and sidebar refresh signatures, so a revision-only change triggers exactly one authoritative node re-fetch while no-op summaries stay refetch-free and no polling is added.

---

### ⚙️ CI / Engineering

- `spec_git/policy.yaml` required checks realigned, 29e876ac2c: SpecGit acceptance for dev deliveries now requires the `Typecheck` and `Unit Tests (linux)` checks instead of the `unit-tests`/`e2e-tests` ids, matching the dev gates where E2E does not block.
- JS SDK regenerated: the generated `DagWorkflowSummary` types carry the new `graphRev` field, and the CI `Check generated SDK` freshness gate passes.

---

### 🧪 Test Summary

- New `packages/tui/test/feature-plugins/dag-panel.test.tsx` regression suite: equal-count replan bumps `graphRev` alone and refetches exactly once; a no-op summary replacement with the same `graphRev` and counts does not refetch.
- Companion coverage: inspector refetch-once (`dag-inspector.test.tsx`), equal-count replan summary emission (`dag-summary-publisher-behavior.test.ts`), core summary store revision (`dag-store-summaries.test.ts`), and the HttpAPI contract exercise asserting `summary.graphRev`.

```
typecheck: root 29/29 tasks green
test:dag-core: pass
test:httpapi:ci: 230 pass / 0 fail
focused suites: inspector 29 pass, sidebar/sync 7 pass
CI @ 29e876ac2c: Typecheck / Unit Tests (linux) / E2E linux+windows / CodeQL all pass
lint: 4836 warnings (ratchet 4850)
```

---

### 🔍 Verification

- DAG development workflow `dag_fe5fa90eeff6BrTFcV9rWDOSyK`: final review ACCEPT after R1 fixes, and `specgit finish --json` exited 0 with the PR #469 checklist complete.
- All 8 GitHub checks on PR #469 green at 29e876ac2c, including E2E on linux (2m38s) and windows (7m19s).
- Scope honesty: the only feature surface in this range is the DAG summary and TUI refresh signature; no upstream sync content, no dependency changes, and no standalone architecture or refactor entries.

---

**Full changelog:** [`{previous_tag}`...`{current_tag}`](https://github.com/LeXwDeX/OpenCode-GraphAgent/compare/{previous_tag}...{current_tag})
8 changes: 4 additions & 4 deletions .specgit.yaml
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
version: 1
delivery: dag-project-discovery
delivery: shell-silence-guard
context:
kind: branch
branch: feat/435-dag-project-discovery
branch: feat/433-shell-silence-guard
issues:
- 435
pr: 490
- 433
pr: 491
288 changes: 0 additions & 288 deletions CLAUDE.md

This file was deleted.

3 changes: 3 additions & 0 deletions packages/core/src/dag/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ export interface WorkflowSummary {
id: string
title: string
status: string
/** Topology invalidation token (#468): bumped by replan, so TUI refresh signatures can detect equal-count replans. */
graphRev: number
nodeCount: number
completedNodes: number
runningNodes: number
Expand Down Expand Up @@ -323,6 +325,7 @@ export const layer = Layer.effect(
id: wf.id,
title: wf.title,
status: wf.status,
graphRev: wf.graph_rev,
...(counts.get(wf.id) ?? { nodeCount: 0, completedNodes: 0, runningNodes: 0, failedNodes: 0, skippedNodes: 0, queuedNodes: 0 }),
escalatedNodes: escalatedByWorkflow.get(wf.id) ?? 0,
}))
Expand Down
13 changes: 12 additions & 1 deletion packages/core/src/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -852,6 +852,13 @@ export const layer = Layer.effect(
})
})

// forceRequired must be derived from locale-stable porcelain state only:
// git's refusal prose is translated (e.g. zh_CN catalogs) and never matches reliably.
const worktreeDirty = Effect.fnUntraced(function* (directory: AbsolutePath) {
const status = yield* execute(directory, proc)(["status", "--porcelain"]).pipe(Effect.result)
return status._tag === "Success" && status.success.exitCode === 0 && status.success.text.trim() !== ""
})

const worktreeRun = Effect.fnUntraced(function* (
operation: "create" | "remove" | "list",
repository: Repository,
Expand All @@ -872,7 +879,11 @@ export const layer = Layer.effect(
operation,
directory: worktreeDirectory,
message,
forceRequired: operation === "remove" && /contains modified or untracked files|is dirty/i.test(message),
forceRequired:
operation === "remove" &&
result.exitCode === 128 &&
worktreeDirectory !== undefined &&
(yield* worktreeDirty(worktreeDirectory)),
})
})

Expand Down
1 change: 1 addition & 0 deletions packages/core/test/dag-rev-view-legacy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ describe("Train A rev-view — legacy rows render unchanged (A-p4 PIN)", () => {
id: "wf-legacy",
title: "Legacy",
status: "running",
graphRev: 1,
nodeCount: 4,
completedNodes: 2,
runningNodes: 0,
Expand Down
2 changes: 2 additions & 0 deletions packages/core/test/dag-store-summaries.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ describe("DagStore.getWorkflowSummaries (SQL aggregation)", () => {
id: "wf-mixed",
title: "Mixed",
status: "running",
graphRev: 1,
nodeCount: 7,
completedNodes: 2,
runningNodes: 1,
Expand All @@ -106,6 +107,7 @@ describe("DagStore.getWorkflowSummaries (SQL aggregation)", () => {
id: "wf-empty",
title: "Empty",
status: "running",
graphRev: 1,
nodeCount: 0,
completedNodes: 0,
runningNodes: 0,
Expand Down
42 changes: 38 additions & 4 deletions packages/core/test/filesystem/watcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,12 +225,33 @@ describeWatcher("Watcher", () => {
const branch = `watch-${Math.random().toString(36).slice(2)}`
yield* ready(directory)
yield* Effect.promise(() => $`git branch ${branch}`.cwd(directory).quiet())
// Contract for an existing HEAD: once the backend stream has seen HEAD, an
// update publishes "change" and removal publishes "unlink". The first write
// to a pre-existing but stream-unseen HEAD is backend vocabulary (fs-events
// may flag ItemCreated, surfacing as "add" on macOS), so it is consumed only
// to record HEAD in the stream — its classification is deliberately not
// asserted, while the asserted "change" and "unlink" must match exactly.
yield* eventuallyUpdate(
(event) => event.file === head,
() => fs.writeFileString(head, `ref: refs/heads/${branch}\n`),
)
const renamed = `watch-${Math.random().toString(36).slice(2)}`
yield* Effect.promise(() => $`git branch ${renamed}`.cwd(directory).quiet())
expect(
yield* nextUpdate((event) => event.file === head, fs.writeFileString(head, `ref: refs/heads/${branch}\n`)),
yield* nextUpdate(
(event) => event.file === head && event.event === "change",
fs.writeFileString(head, `ref: refs/heads/${renamed}\n`),
),
).toEqual({
file: head,
event: "change",
})
expect(
yield* nextUpdate((event) => event.file === head && event.event === "unlink", fs.remove(head)),
).toEqual({
file: head,
event: "unlink",
})
}),
{ git: true },
),
Expand All @@ -247,14 +268,27 @@ describeWatcher("Watcher", () => {
yield* Effect.addFinalizer(() => Effect.promise(() => fs.rm(actual, { recursive: true, force: true })))
yield* ready(directory)
const head = path.join(directory, ".git", "HEAD")
const resolved = path.join(actual, "HEAD")
const branch = `watch-${Math.random().toString(36).slice(2)}`
yield* Effect.promise(() => $`git branch ${branch}`.cwd(directory).quiet())
// Same contract as the direct .git/HEAD case, observed through the symlink:
// events carry the realpath of the store, so both the update and the
// removal are asserted against actual/HEAD exactly.
yield* eventuallyUpdate(
(event) => event.file === resolved,
() => afs.writeFileString(head, `ref: refs/heads/${branch}\n`),
)
const renamed = `watch-${Math.random().toString(36).slice(2)}`
yield* Effect.promise(() => $`git branch ${renamed}`.cwd(directory).quiet())
expect(
yield* nextUpdate(
(event) => event.file === path.join(actual, "HEAD"),
afs.writeFileString(head, `ref: refs/heads/${branch}\n`),
(event) => event.file === resolved && event.event === "change",
afs.writeFileString(head, `ref: refs/heads/${renamed}\n`),
),
).toEqual({ file: path.join(actual, "HEAD"), event: "change" })
).toEqual({ file: resolved, event: "change" })
expect(
yield* nextUpdate((event) => event.file === resolved && event.event === "unlink", afs.remove(head)),
).toEqual({ file: resolved, event: "unlink" })
}),
{
git: true,
Expand Down
38 changes: 38 additions & 0 deletions packages/core/test/project-copy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,44 @@ describe("ProjectCopy", () => {
}),
)

it.live("requires force to remove a git worktree with tracked modifications", () =>
Effect.gen(function* () {
const input = yield* setup()
const copy = yield* ProjectCopy.Service
const temp = yield* Effect.promise(() => fs.realpath(path.dirname(input.root.path)))
const parent = abs(path.join(temp, path.basename(input.root.path) + "-copy-tracked"))
yield* Effect.addFinalizer(() =>
Effect.promise(() => fs.rm(parent, { recursive: true, force: true })).pipe(Effect.ignore),
)
yield* Effect.promise(() => Bun.write(path.join(input.sourceDirectory, "tracked.txt"), "base"))
yield* Effect.promise(() => $`git add tracked.txt`.cwd(input.sourceDirectory).quiet())
yield* Effect.promise(() => $`git commit -m tracked`.cwd(input.sourceDirectory).quiet())
const created = yield* copy.create({
projectID: input.projectID,
strategy: gitWorktree,
sourceDirectory: input.sourceDirectory,
directory: parent,
name: "copy",
})
yield* Effect.promise(() => Bun.write(path.join(created.directory, "tracked.txt"), "modified"))

const error = yield* copy
.remove({ projectID: input.projectID, directory: created.directory, force: false })
.pipe(Effect.flip)

expect(error).toBeInstanceOf(Git.WorktreeError)
if (error instanceof Git.WorktreeError) {
expect(error.operation).toBe("remove")
expect(error.forceRequired).toBe(true)
}
expect(yield* stored(input.projectID)).toContainEqual({ directory: created.directory, strategy: "git_worktree" })
expect(yield* Effect.promise(() => Bun.file(path.join(created.directory, "tracked.txt")).exists())).toBe(true)

yield* copy.remove({ projectID: input.projectID, directory: created.directory, force: true })
expect(yield* Effect.promise(() => Bun.file(created.directory).exists())).toBe(false)
}),
)

it.live("preserves copies whose stored strategy is unavailable", () =>
Effect.gen(function* () {
const input = yield* setup()
Expand Down
1 change: 1 addition & 0 deletions packages/opencode/src/effect/runtime-flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export class Service extends ConfigService.Service<Service>()("@opencode/Runtime
experimentalIconDiscovery: enabledByExperimental("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY"),
outputTokenMax: positiveInteger("OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX"),
bashDefaultTimeoutMs: positiveInteger("OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS"),
bashSilenceWarnMs: positiveInteger("OPENCODE_EXPERIMENTAL_BASH_SILENCE_WARN_MS"),
experimentalNativeLlm: bool("OPENCODE_EXPERIMENTAL_NATIVE_LLM"),
experimentalWebSockets: bool("OPENCODE_EXPERIMENTAL_WEBSOCKETS"),
client: Config.string("OPENCODE_CLIENT").pipe(Config.withDefault("cli")),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ export const WorkflowSummaryResponse = Schema.Struct({
id: Schema.String,
title: Schema.String,
status: Schema.String,
// Topology invalidation token (#468): bumped by replan, so TUI refresh signatures can detect equal-count replans.
graphRev: Schema.Number,
nodeCount: Schema.Number,
completedNodes: Schema.Number,
runningNodes: Schema.Number,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ export const dagHandlers = HttpApiBuilder.group(InstanceHttpApi, "dag", (handler
id: s.id,
title: s.title,
status: s.status,
graphRev: s.graphRev,
nodeCount: s.nodeCount,
completedNodes: s.completedNodes,
runningNodes: s.runningNodes,
Expand Down
37 changes: 36 additions & 1 deletion packages/opencode/src/tool/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ export { Parameters } from "./shell/prompt"
export const SHELL_ABORT_NOTE =
"The command was aborted before completion (client interrupt or session cancel). For long-running work, bound it with `timeout <seconds>` and stream progress instead of piping into a silent buffer."

const DEFAULT_SILENCE_WARN_MS = 5 * 60 * 1000

const shellSilenceNote = (ms: number) =>
`shell tool emitted an inactivity warning after ${ms} ms without output; the command was left running. If this command is expected to stay silent, pass expectedSilent: true to opt out.`

const MAX_METADATA_LENGTH = 30_000
const CWD = new Set(["cd", "chdir", "popd", "pushd", "push-location", "set-location"])
const FILES = new Set([
Expand Down Expand Up @@ -352,6 +357,7 @@ export const ShellTool = Tool.define(
const plugin = yield* Plugin.Service
const flags = yield* RuntimeFlags.Service
const defaultTimeoutMs = flags.bashDefaultTimeoutMs ?? 2 * 60 * 1000
const silenceWarnMs = flags.bashSilenceWarnMs ?? DEFAULT_SILENCE_WARN_MS

const cygpath = Effect.fn("ShellTool.cygpath")(function* (shell: string, text: string) {
const lines = yield* spawner
Expand Down Expand Up @@ -439,6 +445,7 @@ export const ShellTool = Tool.define(
cwd: string
env: NodeJS.ProcessEnv
timeout: number
expectedSilent: boolean
},
ctx: Tool.Context,
) {
Expand All @@ -453,6 +460,9 @@ export const ShellTool = Tool.define(
let cut = false
let expired = false
let aborted = false
let lastActivity = Date.now()
let silenceWarned = false
let silenceWarnings = 0

const closeSink = Effect.fnUntraced(function* () {
const stream = sink
Expand Down Expand Up @@ -492,6 +502,8 @@ export const ShellTool = Tool.define(

const readerFiber = yield* Effect.forkScoped(
Stream.runForEach(Stream.decodeText(handle.all), (chunk) => {
lastActivity = Date.now()
silenceWarned = false
const size = Buffer.byteLength(chunk, "utf-8")
list.push({ text: chunk, size })
used += size
Expand Down Expand Up @@ -537,6 +549,27 @@ export const ShellTool = Tool.define(
}),
)

// Warn-only silence guard: lives on its own fiber and must never
// join the race below — a silence warning may not change exit.kind.
if (!input.expectedSilent) {
yield* Effect.forkScoped(
Effect.forever(
Effect.gen(function* () {
const idle = Date.now() - lastActivity
yield* Effect.sleep(`${idle < silenceWarnMs ? silenceWarnMs - idle : silenceWarnMs} millis`)
if (silenceWarned || Date.now() - lastActivity < silenceWarnMs) return
silenceWarned = true
silenceWarnings++
yield* ctx.metadata({
metadata: {
output: last + `\n\n${shellSilenceNote(silenceWarnMs)}`,
},
})
}),
),
)
}

const abort = Effect.callback<void>((resume) => {
if (ctx.abort.aborted) return resume(Effect.void)
const handler = () => resume(Effect.void)
Expand Down Expand Up @@ -579,6 +612,7 @@ export const ShellTool = Tool.define(
)
}
if (aborted) meta.push(SHELL_ABORT_NOTE)
for (let i = 0; i < silenceWarnings; i++) meta.push(shellSilenceNote(silenceWarnMs))
const raw = list.map((item) => item.text).join("")
const end = tail(raw, limits.maxLines, limits.maxBytes)
if (end.cut) cut = true
Expand Down Expand Up @@ -614,7 +648,7 @@ export const ShellTool = Tool.define(
const shell = Shell.acceptable(cfg.shell)
const name = Shell.name(shell)
const limits = yield* trunc.limits()
const prompt = ShellPrompt.render(name, process.platform, limits, defaultTimeoutMs)
const prompt = ShellPrompt.render(name, process.platform, limits, defaultTimeoutMs, silenceWarnMs)
yield* Effect.logInfo("shell tool using shell", { shell })

return {
Expand Down Expand Up @@ -649,6 +683,7 @@ export const ShellTool = Tool.define(
cwd,
env: yield* shellEnv(ctx, cwd),
timeout,
expectedSilent: params.expectedSilent === true,
},
ctx,
)
Expand Down
Loading
Loading