Skip to content

Commit 2813f16

Browse files
gHashTagAntigravity Agentclaude
authored
feat(bot): /model, /resume, /sessions commands (Issue #56) (#59)
Add session management and model switching to tri-bot: - /model <name> — set Claude model for subsequent requests (persists in BotState) - /resume <id> [msg] — resume session by ID via streaming worker thread - /sessions — list recent sessions with IDs and summaries - StreamOpts struct replaces positional args in claude_stream.runStreaming - spawnStreaming() helper DRYs up thread spawn + error handling Closes #56 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Antigravity Agent <antigravity@vibee.org> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8a77d32 commit 2813f16

3 files changed

Lines changed: 214 additions & 32 deletions

File tree

tools/mcp/trinity_mcp/bot/bot_loop.zig

Lines changed: 59 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
// bot_loop.zig — Main poll → parse → dispatch → repeat loop
2-
// Phase 2: Two-thread arch — main thread polls, worker thread streams Claude output
2+
// Phase 2.5: Two-thread arch + session management (/model, /resume, /sessions)
33
const std = @import("std");
44
const telegram_api = @import("telegram_api.zig");
55
const json_utils = @import("json_utils.zig");
@@ -12,16 +12,19 @@ const BotConfig = telegram_api.BotConfig;
1212
/// Shared state for streaming — module-level, accessible from main + worker threads
1313
var stream_state = claude_stream.StreamState{};
1414

15+
/// Runtime state — model selection, persists across commands within a bot run
16+
var bot_state = handlers.BotState{};
17+
1518
/// Run the bot loop: poll Telegram, parse commands, dispatch handlers.
1619
/// Never returns (infinite loop). Main thread stays responsive while
1720
/// worker thread handles Claude streaming.
1821
pub fn run(allocator: std.mem.Allocator, config: BotConfig) void {
1922
var last_update_id: i64 = 0;
2023

2124
// Announce startup
22-
telegram_api.sendMessage(allocator, config.bot_token, config.chat_id, "\xf0\x9f\xa4\x96 TRI BOT v2.0 online! Send /help for commands.");
25+
telegram_api.sendMessage(allocator, config.bot_token, config.chat_id, "\xf0\x9f\xa4\x96 TRI BOT v3.0 online! Send /help for commands.");
2326

24-
std.debug.print("[tri-bot] Started (Phase 2: streaming). Polling Telegram...\n", .{});
27+
std.debug.print("[tri-bot] Started (Phase 2.5: streaming + sessions). Polling Telegram...\n", .{});
2528

2629
while (true) {
2730
const body = telegram_api.getUpdates(allocator, config.bot_token, last_update_id + 1) orelse {
@@ -87,11 +90,23 @@ fn processUpdates(allocator: std.mem.Allocator, config: BotConfig, body: []const
8790
}
8891
}
8992

93+
/// Helper: check if busy + spawn streaming worker thread.
94+
fn spawnStreaming(allocator: std.mem.Allocator, config: BotConfig, opts: claude_stream.StreamOpts) void {
95+
stream_state.is_busy.store(true, .release);
96+
_ = std.Thread.spawn(.{}, claude_stream.runStreaming, .{ allocator, config, opts, &stream_state }) catch {
97+
stream_state.is_busy.store(false, .release);
98+
allocator.free(opts.args);
99+
if (opts.resume_id) |rid| allocator.free(rid);
100+
telegram_api.sendMessage(allocator, config.bot_token, config.chat_id, "\xe2\x9d\x8c Failed to spawn worker thread");
101+
return;
102+
};
103+
}
104+
90105
fn dispatch(allocator: std.mem.Allocator, config: BotConfig, cmd: command_parser.Command) void {
91106
if (std.mem.eql(u8, cmd.name, "help")) {
92107
handlers.handleHelp(allocator, config);
93108
} else if (std.mem.eql(u8, cmd.name, "ask")) {
94-
// Phase 2: streaming /ask via worker thread
109+
// Streaming /ask via worker thread
95110
if (cmd.args.len == 0) {
96111
telegram_api.sendMessage(allocator, config.bot_token, config.chat_id, "\xe2\x9a\xa0 Usage: /ask <question>");
97112
return;
@@ -100,34 +115,58 @@ fn dispatch(allocator: std.mem.Allocator, config: BotConfig, cmd: command_parser
100115
telegram_api.sendMessage(allocator, config.bot_token, config.chat_id, "\xe2\x8f\xb3 Already processing. Send /stop first.");
101116
return;
102117
}
103-
// Dupe args — they point into getUpdates body which will be freed
104118
const args_owned = allocator.dupe(u8, cmd.args) catch return;
105-
stream_state.is_busy.store(true, .release);
106-
_ = std.Thread.spawn(.{}, claude_stream.runStreaming, .{ allocator, config, args_owned, false, &stream_state }) catch {
107-
stream_state.is_busy.store(false, .release);
108-
allocator.free(args_owned);
109-
telegram_api.sendMessage(allocator, config.bot_token, config.chat_id, "\xe2\x9d\x8c Failed to spawn worker thread");
110-
return;
111-
};
119+
spawnStreaming(allocator, config, .{
120+
.args = args_owned,
121+
.model = bot_state.getModel(),
122+
});
112123
} else if (std.mem.eql(u8, cmd.name, "continue")) {
113-
// Phase 2: streaming /continue via worker thread
124+
// Streaming /continue via worker thread
114125
if (stream_state.is_busy.load(.acquire)) {
115126
telegram_api.sendMessage(allocator, config.bot_token, config.chat_id, "\xe2\x8f\xb3 Already processing. Send /stop first.");
116127
return;
117128
}
118129
const args_owned = allocator.dupe(u8, cmd.args) catch return;
119-
stream_state.is_busy.store(true, .release);
120-
_ = std.Thread.spawn(.{}, claude_stream.runStreaming, .{ allocator, config, args_owned, true, &stream_state }) catch {
121-
stream_state.is_busy.store(false, .release);
122-
allocator.free(args_owned);
123-
telegram_api.sendMessage(allocator, config.bot_token, config.chat_id, "\xe2\x9d\x8c Failed to spawn worker thread");
130+
spawnStreaming(allocator, config, .{
131+
.args = args_owned,
132+
.use_continue = true,
133+
.model = bot_state.getModel(),
134+
});
135+
} else if (std.mem.eql(u8, cmd.name, "resume")) {
136+
// Streaming /resume <id> via worker thread
137+
if (cmd.args.len == 0) {
138+
telegram_api.sendMessage(allocator, config.bot_token, config.chat_id, "\xe2\x9a\xa0 Usage: /resume <session-id>");
139+
return;
140+
}
141+
if (stream_state.is_busy.load(.acquire)) {
142+
telegram_api.sendMessage(allocator, config.bot_token, config.chat_id, "\xe2\x8f\xb3 Already processing. Send /stop first.");
143+
return;
144+
}
145+
// Extract session ID (first word of args)
146+
const id_end = std.mem.indexOf(u8, cmd.args, " ") orelse cmd.args.len;
147+
const resume_id = allocator.dupe(u8, cmd.args[0..id_end]) catch return;
148+
// Remaining text after ID is the prompt (if any)
149+
const prompt_start = if (id_end < cmd.args.len) id_end + 1 else id_end;
150+
const prompt = allocator.dupe(u8, cmd.args[prompt_start..]) catch {
151+
allocator.free(resume_id);
124152
return;
125153
};
154+
spawnStreaming(allocator, config, .{
155+
.args = prompt,
156+
.resume_id = resume_id,
157+
.model = bot_state.getModel(),
158+
});
159+
} else if (std.mem.eql(u8, cmd.name, "model")) {
160+
// Blocking: set/show model
161+
handlers.handleModel(allocator, config, cmd.args, &bot_state);
162+
} else if (std.mem.eql(u8, cmd.name, "sessions")) {
163+
// Blocking: list sessions
164+
handlers.handleSessions(allocator, config);
126165
} else if (std.mem.eql(u8, cmd.name, "status")) {
127-
// Status stays blocking (short query, no streaming needed)
166+
// Blocking: project status
128167
handlers.handleStatus(allocator, config);
129168
} else if (std.mem.eql(u8, cmd.name, "stop")) {
130-
// Phase 2: /stop kills active Claude process
169+
// Kill active Claude process
131170
claude_stream.stopProcess(allocator, config, &stream_state);
132171
} else if (cmd.name.len > 0) {
133172
var buf: [256]u8 = undefined;

tools/mcp/trinity_mcp/bot/claude_stream.zig

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,23 +14,33 @@ pub const StreamState = struct {
1414
active_pid: std.atomic.Value(i32) = std.atomic.Value(i32).init(0),
1515
};
1616

17+
/// Options for streaming — covers /ask, /continue, and /resume.
18+
pub const StreamOpts = struct {
19+
args: []const u8, // prompt text (caller-owned, freed by worker)
20+
use_continue: bool = false,
21+
resume_id: ?[]const u8 = null, // session ID for --resume
22+
model: ?[]const u8 = null, // model override (pointer to BotState buffer)
23+
};
24+
1725
/// Worker thread entry point: spawn claude, stream output, send drafts.
1826
/// Called via std.Thread.spawn() from bot_loop dispatch.
1927
pub fn runStreaming(
2028
allocator: std.mem.Allocator,
2129
config: BotConfig,
22-
args_owned: []const u8,
23-
use_continue: bool,
30+
opts: StreamOpts,
2431
state: *StreamState,
2532
) void {
2633
defer {
2734
state.active_pid.store(0, .release);
2835
state.is_busy.store(false, .release);
29-
allocator.free(args_owned);
36+
allocator.free(opts.args);
37+
if (opts.resume_id) |rid| allocator.free(rid);
3038
}
3139

3240
// Notify user
33-
if (use_continue) {
41+
if (opts.resume_id != null) {
42+
telegram_api.sendMessage(allocator, config.bot_token, config.chat_id, "\xf0\x9f\x94\x84 TRI resuming session...");
43+
} else if (opts.use_continue) {
3444
telegram_api.sendMessage(allocator, config.bot_token, config.chat_id, "\xf0\x9f\x94\x84 TRI streaming...");
3545
} else {
3646
telegram_api.sendMessage(allocator, config.bot_token, config.chat_id, "\xf0\x9f\xa7\xa0 TRI streaming...");
@@ -40,22 +50,33 @@ pub fn runStreaming(
4050
var turns_buf: [16]u8 = undefined;
4151
const turns_str = std.fmt.bufPrint(&turns_buf, "{d}", .{config.max_turns}) catch "10";
4252

43-
// Build argv dynamically based on args and --continue flag
44-
var argv_buf: [12][]const u8 = undefined;
53+
// Build argv dynamically based on opts
54+
var argv_buf: [16][]const u8 = undefined;
4555
var argc: usize = 0;
4656

4757
argv_buf[argc] = "claude";
4858
argc += 1;
49-
if (args_owned.len > 0) {
59+
if (opts.args.len > 0) {
5060
argv_buf[argc] = "-p";
5161
argc += 1;
52-
argv_buf[argc] = args_owned;
62+
argv_buf[argc] = opts.args;
5363
argc += 1;
5464
}
55-
if (use_continue) {
65+
if (opts.resume_id) |rid| {
66+
argv_buf[argc] = "--resume";
67+
argc += 1;
68+
argv_buf[argc] = rid;
69+
argc += 1;
70+
} else if (opts.use_continue) {
5671
argv_buf[argc] = "--continue";
5772
argc += 1;
5873
}
74+
if (opts.model) |model| {
75+
argv_buf[argc] = "--model";
76+
argc += 1;
77+
argv_buf[argc] = model;
78+
argc += 1;
79+
}
5980
argv_buf[argc] = "--output-format";
6081
argc += 1;
6182
argv_buf[argc] = "stream-json";

tools/mcp/trinity_mcp/bot/handlers.zig

Lines changed: 125 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,42 @@
22
// Each handler: receives args + config, runs claude CLI, sends result to Telegram
33
const std = @import("std");
44
const telegram_api = @import("telegram_api.zig");
5+
const json_utils = @import("json_utils.zig");
56

67
const BotConfig = telegram_api.BotConfig;
78

9+
/// Runtime state that persists across commands within a bot run.
10+
pub const BotState = struct {
11+
model_buf: [64]u8 = undefined,
12+
model_len: usize = 0,
13+
14+
pub fn getModel(self: *const BotState) ?[]const u8 {
15+
if (self.model_len == 0) return null;
16+
return self.model_buf[0..self.model_len];
17+
}
18+
19+
pub fn setModel(self: *BotState, name: []const u8) void {
20+
const len = @min(name.len, self.model_buf.len);
21+
@memcpy(self.model_buf[0..len], name[0..len]);
22+
self.model_len = len;
23+
}
24+
};
25+
826
/// /help — Send list of available commands
927
pub fn handleHelp(allocator: std.mem.Allocator, config: BotConfig) void {
1028
const help_text =
1129
"\xf0\x9f\xa4\x96 TRI BOT \xe2\x80\x94 Claude Code Remote Control\n" ++
1230
"\n" ++
1331
"/ask <question> \xe2\x80\x94 Ask Claude (streaming)\n" ++
14-
"/continue [question] \xe2\x80\x94 Continue session (streaming)\n" ++
32+
"/continue [msg] \xe2\x80\x94 Continue session\n" ++
33+
"/resume <id> \xe2\x80\x94 Resume session by ID\n" ++
34+
"/sessions \xe2\x80\x94 List recent sessions\n" ++
35+
"/model <name> \xe2\x80\x94 Set Claude model\n" ++
1536
"/status \xe2\x80\x94 Project status\n" ++
16-
"/stop \xe2\x80\x94 Stop running Claude process\n" ++
37+
"/stop \xe2\x80\x94 Stop running process\n" ++
1738
"/help \xe2\x80\x94 This message\n" ++
1839
"\n" ++
19-
"Phase 3: /resume, /model, /board, /pr, /worktree";
40+
"Phase 3: /board, /pr, /worktree";
2041
telegram_api.sendMessage(allocator, config.bot_token, config.chat_id, help_text);
2142
}
2243

@@ -119,3 +140,104 @@ pub fn handleStatus(allocator: std.mem.Allocator, config: BotConfig) void {
119140

120141
telegram_api.sendLongMessage(allocator, config, result.stdout);
121142
}
143+
144+
/// /model <name> — Set Claude model for subsequent requests.
145+
pub fn handleModel(allocator: std.mem.Allocator, config: BotConfig, args: []const u8, bot_state: *BotState) void {
146+
if (args.len == 0) {
147+
// Show current model
148+
if (bot_state.getModel()) |model| {
149+
var buf: [256]u8 = undefined;
150+
telegram_api.sendFmt(allocator, config.bot_token, config.chat_id, &buf, "\xf0\x9f\xa4\x96 Current model: {s}", .{model});
151+
} else {
152+
telegram_api.sendMessage(allocator, config.bot_token, config.chat_id, "\xf0\x9f\xa4\x96 Model: default (no override)");
153+
}
154+
return;
155+
}
156+
157+
if (args.len > 64) {
158+
telegram_api.sendMessage(allocator, config.bot_token, config.chat_id, "\xe2\x9a\xa0 Model name too long (max 64 chars)");
159+
return;
160+
}
161+
162+
bot_state.setModel(args);
163+
var buf: [256]u8 = undefined;
164+
telegram_api.sendFmt(allocator, config.bot_token, config.chat_id, &buf, "\xe2\x9c\x85 Model set: {s}", .{args});
165+
}
166+
167+
/// /sessions — List recent Claude sessions.
168+
pub fn handleSessions(allocator: std.mem.Allocator, config: BotConfig) void {
169+
telegram_api.sendMessage(allocator, config.bot_token, config.chat_id, "\xf0\x9f\x93\x8b Fetching sessions...");
170+
171+
const result = std.process.Child.run(.{
172+
.allocator = allocator,
173+
.argv = &.{ "claude", "sessions", "list", "--json" },
174+
.cwd = config.project_root,
175+
.max_output_bytes = 128 * 1024,
176+
}) catch |err| {
177+
var err_buf: [256]u8 = undefined;
178+
telegram_api.sendFmt(allocator, config.bot_token, config.chat_id, &err_buf, "\xe2\x9d\x8c Sessions error: {s}", .{@errorName(err)});
179+
return;
180+
};
181+
defer allocator.free(result.stdout);
182+
defer allocator.free(result.stderr);
183+
184+
if (result.stdout.len == 0) {
185+
telegram_api.sendMessage(allocator, config.bot_token, config.chat_id, "\xe2\x9a\xa0 No sessions found");
186+
return;
187+
}
188+
189+
// Format sessions: extract id + summary from JSON array
190+
var out_buf: std.ArrayList(u8) = .empty;
191+
defer out_buf.deinit(allocator);
192+
193+
out_buf.appendSlice(allocator, "\xf0\x9f\x93\x8b Recent sessions:\n") catch return;
194+
formatSessions(allocator, &out_buf, result.stdout);
195+
196+
if (out_buf.items.len > 20) {
197+
telegram_api.sendLongMessage(allocator, config, out_buf.items);
198+
} else {
199+
telegram_api.sendMessage(allocator, config.bot_token, config.chat_id, "\xe2\x9a\xa0 Could not parse sessions");
200+
}
201+
}
202+
203+
/// Parse JSON session list and append formatted entries to output buffer.
204+
fn formatSessions(allocator: std.mem.Allocator, out: *std.ArrayList(u8), json: []const u8) void {
205+
var count: usize = 0;
206+
var pos: usize = 0;
207+
const id_needle = "\"session_id\":\"";
208+
209+
while (pos < json.len and count < 10) {
210+
const idx = std.mem.indexOfPos(u8, json, pos, id_needle) orelse break;
211+
const id_start = idx + id_needle.len;
212+
const id_end = std.mem.indexOfPos(u8, json, id_start, "\"") orelse break;
213+
const session_id = json[id_start..id_end];
214+
215+
// Try to find summary near this session entry
216+
const block_end = std.mem.indexOfPos(u8, json, id_end, id_needle) orelse json.len;
217+
const block = json[idx..block_end];
218+
const summary = json_utils.extractString(block, "summary") orelse
219+
json_utils.extractString(block, "name") orelse "(no summary)";
220+
221+
count += 1;
222+
var num_buf: [4]u8 = undefined;
223+
const num_str = std.fmt.bufPrint(&num_buf, "{d}", .{count}) catch "?";
224+
out.appendSlice(allocator, num_str) catch {};
225+
out.appendSlice(allocator, ". ") catch {};
226+
// Show short ID (first 8 chars or full if shorter)
227+
const short_id = if (session_id.len > 8) session_id[0..8] else session_id;
228+
out.appendSlice(allocator, short_id) catch {};
229+
out.appendSlice(allocator, " \xe2\x80\x94 ") catch {};
230+
// Truncate summary to 60 chars
231+
const max_sum: usize = 60;
232+
const sum_display = if (summary.len > max_sum) summary[0..max_sum] else summary;
233+
out.appendSlice(allocator, sum_display) catch {};
234+
if (summary.len > max_sum) out.appendSlice(allocator, "...") catch {};
235+
out.appendSlice(allocator, "\n") catch {};
236+
237+
pos = block_end;
238+
}
239+
240+
if (count == 0) {
241+
out.appendSlice(allocator, "(no sessions found)\n") catch {};
242+
}
243+
}

0 commit comments

Comments
 (0)