Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
4fee284
fix(readline): make pasted text placeholders atomic
yxlyx Aug 29, 2026
4087b95
Reuse warmed TLS on MCP HTTP and WSS reconnects
cursoragent Aug 29, 2026
9b81ec4
Fix source-guard needles that matched their own test text
cursoragent Aug 29, 2026
4589a70
Accept gzip on MCP HTTP so catalogs shrink on the wire
cursoragent Aug 29, 2026
54fd5f3
docs(adr): record measured before/after for network reuse
cursoragent Aug 30, 2026
1a13511
fix(codex): preserve terminal websocket API errors
yxlyx Aug 30, 2026
c7a5426
fix(tui): recover poisoned TLS client generations
yxlyx Aug 30, 2026
4841520
test(ci): avoid version-sensitive string repetition
yxlyx Aug 30, 2026
adfbf77
Merge #674 into release/v0.0.282: atomic pasted-text placeholders
cursoragent Aug 31, 2026
20de3d4
Merge #693 into release/v0.0.282: preserve terminal Codex WS API errors
cursoragent Aug 31, 2026
d948b77
Merge #694 into release/v0.0.282: recover poisoned TLS client generat…
cursoragent Aug 31, 2026
2342255
docs(release): cut v0.0.282 notes from yxlyx #674 #693 #694
cursoragent Aug 31, 2026
da6511b
fix: split request scratch out of agent_request.zig (600-line ceiling)
cursoragent Aug 31, 2026
f334bb5
fix(tui): harden TLS recovery lifecycle
yxlyx Aug 31, 2026
e213723
fix: add isolated session branching
yxlyx Aug 31, 2026
1bc0ff7
Merge origin/main into release/v0.0.282
cursoragent Aug 31, 2026
9930f2b
Merge #694 follow-up into release/v0.0.282: harden TLS recovery lifec…
cursoragent Aug 31, 2026
a644cc8
fix: keep ADR 0042–0048 after the #694 follow-up merge
cursoragent Aug 31, 2026
642924a
Merge #697 into release/v0.0.282: clone-on-write session branches
cursoragent Aug 31, 2026
c1c6522
Merge #679 into release/v0.0.282: reuse warmed TLS on MCP HTTP and WSS
cursoragent Aug 31, 2026
c607fc0
docs(release): 282 notes for #697 session branches and #679 TLS reuse
cursoragent Aug 31, 2026
f046f1c
fix: keep agent.zig under 600 lines; join the grok-spec mock before c…
cursoragent Aug 31, 2026
c624d6d
fix: omit an empty tool catalog from every request body (#695)
justrach Aug 31, 2026
4c92f56
test: ratchet the 282 suite floor to 1848
cursoragent Aug 31, 2026
6723ec4
Merge #695 into release/v0.0.282: omit an empty tool catalog from eve…
justrach Aug 31, 2026
cd68b07
Merge remote release/v0.0.282: keep #695 empty-catalog omit
cursoragent Aug 31, 2026
b1fcf67
docs(release): note #695 empty-catalog omit on 282
cursoragent Aug 31, 2026
19ff632
test: ratchet the 282 suite floor to 1849
cursoragent Aug 31, 2026
62ffa9b
docs(eval): remasure 12-task in-house frontier after #697
cursoragent Aug 31, 2026
999a333
fix: leave headroom for pinned debug builds
yxlyx Aug 31, 2026
493627c
fix: keep compaction handoffs out of source nudges
yxlyx Aug 31, 2026
cf195f6
test: stabilize Codex compaction wire assertions
yxlyx Aug 31, 2026
5897976
docs(release): fold #698 follow-ups onto 282
cursoragent Aug 31, 2026
da50c12
docs(release): note #277/#200 closed in the 282 changelog
cursoragent Aug 31, 2026
217d8fd
Merge main into release/v0.0.282: the v0.0.281 deep-overview release …
justrach Aug 31, 2026
2211fc7
feat: fold #677 teleport/GC and #675 DeepSeek lean bash into 282
cursoragent Aug 31, 2026
f95f156
fix: route Windows mid-handshake TLS aborts through the construction …
justrach Aug 31, 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
31 changes: 31 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,37 @@ The release workflow uses a tag's section here as its release notes (a
hand-written `docs/releases/<tag>.md` wins if present), so keeping this file
current is part of cutting a release.

## v0.0.282 (2026-08-31)

- Next cut after v0.0.281 landed on main (`#670`). First fold:
atomic paste spans (`#674`), terminal Codex WS API errors
(`#693`), and recoverable HTTP client generations (`#694` /
ADR 0048). `#694` follow-up hardens lease teardown.
- `#697` / ADR 0049: `--resume SOURCE --branch DEST` clone-on-write
so two processes cannot clobber one `.session.json`. `#698` follow-ups:
160 MiB learn-pin headroom for debug builds, source-nudge suffixes no
longer match `.json` / `.jsonl` as JavaScript, Codex compaction probe
reads Responses `input_text` blocks.
- `#679` / ADR 0050: MCP HTTP/WSS reuse the warmed TLS client;
MCP HTTP accepts gzip catalogs.
- `#695`: an empty tool catalog omits the `tools` field instead of
serializing `"tools":,` (0.0.280 HTTP 400).
- `#694`/`#697`/`#679` numbered records 0042/0043; those slots are
TUI claims and Pi SWE. Remapped to **0048–0050**.
- `#677` / ADR 0051: `/teleport` restores a snapshot tar onto another
CLI backend; `/snapshot gc` keeps the newest n trees. Leftover of
`#554`. `#677` numbered this 0042; remapped (0042 is TUI claims).
- `#675` / ADR 0052–0055: lean `-p` bounces a prose-only first "done";
short Codegraff flakes retry twice; DeepSeek family default low
sends `thinking.type=disabled`; lean bash auto-backgrounds at 15s.
`#675` numbered those 0048–0051; remapped off 282's 0048–0051.
- `#277` (Smolify OAuth) and `#200` (idle localhost) closed as
abandoned vs tip; issues stay parked.
- Suite floor **1861** (was 1849). Scratch helpers live in
`agent_request_scratch.zig` so the request loop stays under 600.
- 12-task in-house remasure after #697 still unique vs grok / OpenCode:
**12/12 · 201s · 52 calls · $0.34** (`run-20260831-021035-composite`).

## v0.0.281 (2026-08-29)

- Next cut after tagged v0.0.280. Headline is the **hardlink**
Expand Down
14 changes: 7 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,17 +54,17 @@ Same grok-4.6, same SuperGrok seat, same tasks. graff vs grok-build vs OpenCode.
Lower is better on every named axis. Full tables:
[graff-evals/hillclimb/baseline.md](graff-evals/hillclimb/baseline.md).

**12 shipped-PR fixtures** (`run-20260830-141658`, `--suite inhouse`):
**12 shipped-PR fixtures** (`run-20260831-021035-composite` on 282, `--suite inhouse`):

| harness | pass | wall | calls | tokens | list$ | RSS |
|---|---:|---:|---:|---:|---:|---:|
| **graff** | **12/12** | **192s** | **52** | **228k** | **$0.35** | **92M** |
| grok-build | 12/12 | 462s | 63 | 1.18M | $1.12 | 170M |
| OpenCode | 12/12 | 236s | 74 | 675k | $0.79 | 1.1G |
| **graff** | **12/12** | **201s** | **52** | **230k** | **$0.34** | **9M** |
| grok-build | 12/12 | 388s | 64 | 1.17M | $1.05 | 161M |
| OpenCode | 12/12 | 310s | 81 | 737k | $0.93 | 1.1G |

Graff is the unique frontier on pass, wall, calls, tokens, list$, and RSS.
(First-token is not scored on that run — graff's `0.02s` is a boot mark, not
first model SSE.)
Graff is still the unique frontier on pass, wall, calls, tokens, list$, and RSS
after folding #697. (First-token is not scored — graff's `0.02s` is a boot
mark, not first model SSE. RSS is ReleaseSafe process peak.)

On the 3-task spine (exact-reply + file-ops + fix-fib) graff was **19.9s /
8 calls / $0.048** vs grok 32.3s / 8 / $0.147 and OpenCode 31.2s / 8 / $0.101.
Expand Down
1 change: 1 addition & 0 deletions TUI/catalog.zig
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ pub const Item = struct {

pub const items = [_]Item{
.{ .name = "/new", .desc = "Start a fresh session", .aliases = &.{"/clear"} },
.{ .name = "/resume", .desc = "Resume or branch a saved session" },
.{ .name = "/home", .desc = "Return to the welcome screen", .aliases = &.{"/welcome"} },
.{ .name = "/compact", .desc = "Engine-compact model-visible history" },
.{ .name = "/context", .desc = "Show context-window use" },
Expand Down
18 changes: 17 additions & 1 deletion TUI/dispatch.zig
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const bgop = @import("bgop.zig");
const catalog = @import("catalog.zig");
const engine = @import("engine.zig");
const peer_cmd = @import("peer_cmd.zig");
const resume_mod = @import("resume.zig");
const meters = @import("meters.zig");
const theme_mod = @import("theme.zig");
const turn = @import("turn.zig");
Expand Down Expand Up @@ -58,7 +59,7 @@ pub fn runCommand(self: *Model, line: []const u8) Effect {
// #521: history-destroying commands must not run under a live job — the
// steer guard in promptKey only covers plain text, and the slash menu,
// palette, and steer drain all land here.
const destroys = std.mem.eql(u8, canon, "/new") or std.mem.eql(u8, canon, "/compact") or std.mem.eql(u8, canon, "/rewind");
const destroys = std.mem.eql(u8, canon, "/new") or std.mem.eql(u8, canon, "/compact") or std.mem.eql(u8, canon, "/rewind") or std.mem.eql(u8, canon, "/resume");
if (self.pending != null and destroys) {
self.push(.system, "a turn is still running — press Esc to cancel it first") catch {};
return .stay;
Expand All @@ -76,6 +77,8 @@ pub fn runCommand(self: *Model, line: []const u8) Effect {
_ = self.newSession(); // the `destroys` guard above already refused a live call
self.push(.system, "started a new conversation") catch {};
self.screen = .welcome;
} else if (std.mem.eql(u8, canon, "/resume")) {
resume_mod.run(self, arg);
} else if (std.mem.eql(u8, canon, "/home")) {
self.screen = .welcome;
self.focus = .prompt;
Expand Down Expand Up @@ -142,17 +145,21 @@ pub fn runCommand(self: *Model, line: []const u8) Effect {
self.pushFmt(.system, "fast: {s}", .{onOff(self.fast)}) catch {};
} else if (std.mem.eql(u8, canon, "/ultracode")) {
self.ultracode = !self.ultracode;
publishState(self);
self.pushFmt(.system, "ultracode: {s}", .{onOff(self.ultracode)}) catch {};
} else if (std.mem.eql(u8, canon, "/strict")) {
self.strict = !self.strict;
publishState(self);
self.pushFmt(.system, "strict: {s}", .{onOff(self.strict)}) catch {};
} else if (std.mem.eql(u8, canon, "/goal")) {
if (self.goal) |g| self.alloc.free(g);
self.goal = if (arg.len > 0) (self.alloc.dupe(u8, arg) catch null) else null;
publishState(self);
if (self.goal) |g| self.pushFmt(.system, "goal set: {s}", .{g}) catch {} else self.push(.system, "goal cleared") catch {};
} else if (std.mem.eql(u8, canon, "/rename")) {
if (self.session_name) |s| self.alloc.free(s);
self.session_name = if (arg.len > 0) (self.alloc.dupe(u8, arg) catch null) else null;
publishState(self);
self.pushFmt(.system, "session: {s}", .{self.session_name orelse "untitled"}) catch {};
} else if (std.mem.eql(u8, canon, "/session-info")) {
meters.sessionInfo(self);
Expand Down Expand Up @@ -208,6 +215,15 @@ pub fn runCommand(self: *Model, line: []const u8) Effect {
return if (self.quit_requested) .quit else .stay;
}

fn publishState(self: *Model) void {
if (engine.g_state_fn) |f| f(engine.g_turn_ctx, .{
.session_name = self.session_name orelse "",
.goal = self.goal orelse "",
.strict = self.strict,
.ultracode = self.ultracode,
});
}

/// `!cmd` — run a shell line locally (grok-style bash mode) on a background
/// thread, so a slow command no longer freezes the frame for its whole 20s
/// cap (#533). Output stays out of the model history: EntryKind.system never
Expand Down
28 changes: 28 additions & 0 deletions TUI/engine.zig
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,24 @@ pub const CompactFn = *const fn (turn_ctx: ?*anyopaque, gpa: std.mem.Allocator,
pub const HistoryOp = enum { reset, rewind };
pub const HistoryFn = *const fn (turn_ctx: ?*anyopaque, op: HistoryOp) void;

pub const SessionState = struct {
session_name: []const u8 = "",
goal: []const u8 = "",
strict: bool = false,
ultracode: bool = false,
};
pub const StateFn = *const fn (turn_ctx: ?*anyopaque, state: SessionState) void;

pub const ResumeOut = struct {
turns: []Turn = &.{},
session_name: []const u8 = "",
goal: []const u8 = "",
strict: bool = false,
ultracode: bool = false,
note: []const u8 = "",
};
pub const ResumeFn = *const fn (turn_ctx: ?*anyopaque, gpa: std.mem.Allocator, spec: []const u8, out: *ResumeOut) bool;

pub const Job = struct {
thread: std.Thread = undefined,
threaded: bool = true,
Expand Down Expand Up @@ -238,6 +256,11 @@ pub const RunOpts = struct {
cancel_fn: ?CancelFn = null,
model_name: []const u8 = "",
model_provider: []const u8 = "",
initial_history: []const Turn = &.{},
session_name: []const u8 = "",
initial_goal: []const u8 = "",
initial_strict: bool = false,
initial_ultracode: bool = false,
/// The model catalog with its provider column (see ModelEntry).
model_entries: []const ModelEntry = &.{},
cwd: []const u8 = ".",
Expand All @@ -249,6 +272,9 @@ pub const RunOpts = struct {
copy_fn: ?CopyFn = null,
compact_fn: ?CompactFn = null,
history_fn: ?HistoryFn = null,
resume_fn: ?ResumeFn = null,
state_fn: ?StateFn = null,
emergency_fn: ?*const fn (turn_ctx: ?*anyopaque) void = null,
idle_wake_fn: ?IdleWakeFn = null,
peer_fn: ?PeerFn = null,
};
Expand All @@ -264,6 +290,8 @@ pub var g_files_fn: ?FilesFn = null;
pub var g_copy_fn: ?CopyFn = null;
pub var g_compact_fn: ?CompactFn = null;
pub var g_history_fn: ?HistoryFn = null;
pub var g_resume_fn: ?ResumeFn = null;
pub var g_state_fn: ?StateFn = null;

/// Tell the engine the transcript was cut. Silent when nothing is wired
/// (offline TUI, unit tests).
Expand Down
70 changes: 70 additions & 0 deletions TUI/resume.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
//! Fullscreen `/resume SOURCE [--branch DEST]` projection.

const std = @import("std");
const app = @import("app.zig");
const engine = @import("engine.zig");

pub fn run(self: *app.Model, spec: []const u8) void {
if (spec.len == 0) {
self.push(.system, "usage: /resume SOURCE [--branch DEST]") catch {};
return;
}
const callback = engine.g_resume_fn orelse {
self.push(.system, "resume isn't available (offline)") catch {};
return;
};
var out: engine.ResumeOut = .{};
if (!callback(engine.g_turn_ctx, self.alloc, spec, &out)) {
if (out.note.len > 0) {
self.push(.err, out.note) catch {};
self.alloc.free(out.note);
} else self.push(.err, "resume failed") catch {};
return;
}
self.clearHistory();
for (out.turns) |turn| {
self.push(if (turn.role == .user) .user else .assistant, turn.text) catch {};
self.alloc.free(turn.text);
}
if (out.turns.len > 0) self.alloc.free(out.turns);
self.turns = self.userTurnCount();
if (self.session_name) |old| self.alloc.free(old);
self.session_name = out.session_name;
if (self.goal) |old| self.alloc.free(old);
self.goal = if (out.goal.len > 0) out.goal else null;
self.strict = out.strict;
self.ultracode = out.ultracode;
if (out.note.len > 0) {
self.push(.system, out.note) catch {};
self.alloc.free(out.note);
}
}

fn fakeResume(_: ?*anyopaque, gpa: std.mem.Allocator, spec: []const u8, out: *engine.ResumeOut) bool {
if (!std.mem.eql(u8, spec, "base --branch child")) return false;
const turns = gpa.alloc(engine.Turn, 2) catch return false;
turns[0] = .{ .role = .user, .text = gpa.dupe(u8, "baseline prompt") catch return false };
turns[1] = .{ .role = .assistant, .text = gpa.dupe(u8, "baseline answer") catch return false };
out.* = .{
.turns = turns,
.session_name = gpa.dupe(u8, "child") catch return false,
.note = gpa.dupe(u8, "branched base → child") catch return false,
};
return true;
}

test "fullscreen resume replaces transcript and selects the branch" {
const saved = engine.g_resume_fn;
defer engine.g_resume_fn = saved;
engine.g_resume_fn = fakeResume;
var model: app.Model = undefined;
model.setup(std.testing.allocator);
defer model.deinit();
try model.push(.user, "stale prompt");
run(&model, "base --branch child");
try std.testing.expectEqualStrings("child", model.session_name.?);
try std.testing.expectEqual(@as(usize, 3), model.history.items.len);
try std.testing.expectEqualStrings("baseline prompt", model.history.items[0].text);
try std.testing.expectEqualStrings("baseline answer", model.history.items[1].text);
try std.testing.expectEqualStrings("branched base → child", model.history.items[2].text);
}
9 changes: 9 additions & 0 deletions TUI/root.zig
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,17 @@ pub const CompactOut = engine.CompactOut;
pub const CompactFn = engine.CompactFn;
pub const HistoryOp = engine.HistoryOp;
pub const HistoryFn = engine.HistoryFn;
pub const ResumeOut = engine.ResumeOut;
pub const ResumeFn = engine.ResumeFn;
pub const SessionState = engine.SessionState;
pub const StateFn = engine.StateFn;
pub const PeerFn = engine.PeerFn;
pub const RunOpts = run_mod.RunOpts;
pub const run = run_mod.run;
pub fn setCurrentModel(name: []const u8, provider: []const u8) void {
engine.g_model_name = name;
engine.g_model_provider = provider;
}
pub const restore = @import("restore.zig");
pub const enable_seq = run_mod.enable_seq;

Expand All @@ -62,6 +70,7 @@ test {
_ = app;
_ = @import("app_tests.zig");
_ = @import("dispatch.zig");
_ = @import("resume.zig");
_ = @import("peer_cmd.zig");
_ = @import("peer_tests.zig");
_ = @import("prompt_history.zig");
Expand Down
15 changes: 15 additions & 0 deletions TUI/run.zig
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ pub fn run(
engine.g_copy_fn = opts.copy_fn;
engine.g_compact_fn = opts.compact_fn;
engine.g_history_fn = opts.history_fn;
engine.g_resume_fn = opts.resume_fn;
engine.g_state_fn = opts.state_fn;
engine.g_idle_wake_fn = opts.idle_wake_fn;
engine.g_peer_fn = opts.peer_fn;
engine.g_model_name = opts.model_name;
Expand All @@ -65,6 +67,18 @@ pub fn run(
var m: Model = undefined;
m.setup(gpa);
defer m.deinit();
defer if (opts.state_fn) |f| f(opts.turn_ctx, .{
.session_name = m.session_name orelse "",
.goal = m.goal orelse "",
.strict = m.strict,
.ultracode = m.ultracode,
});
for (opts.initial_history) |item| m.push(if (item.role == .user) .user else .assistant, item.text) catch {};
m.turns = m.userTurnCount();
if (opts.session_name.len > 0) m.session_name = gpa.dupe(u8, opts.session_name) catch null;
if (opts.initial_goal.len > 0) m.goal = gpa.dupe(u8, opts.initial_goal) catch null;
m.strict = opts.initial_strict;
m.ultracode = opts.initial_ultracode;
if (opts.yolo) m.mode = .always_approve;

const claimed = restore_mod.takeClaim();
Expand Down Expand Up @@ -439,6 +453,7 @@ pub fn run(
// The threads are still writing into the job and the op, so the
// process must not outlive the restore: put the terminal back with
// the same bytes the defers would have written, then leave.
if (opts.emergency_fn) |f| f(opts.turn_ctx);
w.flush() catch {};
restore_mod.emergency();
std.process.exit(0);
Expand Down
37 changes: 37 additions & 0 deletions TUI/sim.zig
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,12 @@
const std = @import("std");
const app = @import("app.zig");
const dump = @import("dump.zig");
const engine_mod = @import("engine.zig");
const key_mod = @import("key.zig");
const keys = @import("keys.zig");
const render_mod = @import("render.zig");
const theme_mod = @import("theme.zig");
const turn = @import("turn.zig");

const Model = app.Model;
const Effect = app.Effect;
Expand Down Expand Up @@ -221,6 +223,41 @@ test "typeText lands in the composer dump" {
try std.testing.expect(std.mem.indexOf(u8, vis, "›") != null);
}

test "#692: terminal API diagnostics survive job finish and the composer recovers" {
const a = std.testing.allocator;
var term: Term = undefined;
term.init(a, 80, 24);
defer term.deinit();

try term.model.push(.user, "trigger a provider rejection");
try term.model.push(.pending, "");
const job = try a.create(engine_mod.Job);
job.* = .{
.gpa = a,
.history = &.{},
.params = .{},
.stream = .{},
.raw = .{},
.threaded = false,
.result = try a.dupe(u8, "codex api error [invalid_request_error]: mock bad request"),
};
job.events.attach(a);
job.done.store(true, .release);
term.model.pending = job;
turn.finishJob(&term.model);

const failed = try term.screen();
defer a.free(failed);
try std.testing.expect(std.mem.indexOf(u8, failed, "invalid_request_error") != null);
try std.testing.expect(std.mem.indexOf(u8, failed, "check /model and your API key") == null);

_ = term.typeText("continue");
const recovered = try term.screen();
defer a.free(recovered);
try std.testing.expect(std.mem.indexOf(u8, recovered, "continue") != null);
try std.testing.expect(std.mem.indexOf(u8, recovered, "›") != null);
}

test "assistant markdown shows lists, quotes, tasks, and rules" {
var term: Term = undefined;
term.init(std.testing.allocator, 80, 24);
Expand Down
3 changes: 2 additions & 1 deletion docs/adr/0037-experiment-pool-is-opt-in.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,5 @@ path, branch, keep-reason, and diffstat. `graff worktree list` tags them
## Consequences

Seats are FIFO. A fourth child after `--experiment 3` uses normal isolation.
Dependent pipeline stages still need #295. Docker snapshots stay #554.
Dependent pipeline stages still need #295. Docker snapshots stay #554;
teleport and snapshot GC are ADR 0051.
Loading