Skip to content

Commit 1f4ca21

Browse files
feat: stream all content blocks as activities to Linear
Event mappers now return ActivityContent[] instead of single items. For Claude, assistant messages with both text and tool_use blocks now emit separate activities for each — previously only the first block was returned, dropping whichever came second. Codex and Gemini mappers updated for consistent interface (no behavioral change since their events are one-activity-per-event). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 91d7da3 commit 1f4ca21

4 files changed

Lines changed: 44 additions & 42 deletions

File tree

src/infra/tmux-runner.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ export interface RunInTmuxOptions {
3333
timeoutMs: number;
3434
watchdogMs: number;
3535
logPath: string;
36-
mapEvent: (event: any) => ActivityContent | null;
36+
mapEvent: (event: any) => ActivityContent[];
3737
linearApi?: LinearAgentApi;
3838
agentSessionId?: string;
3939
steeringMode: "stdin-pipe" | "one-shot";
@@ -247,8 +247,8 @@ export async function runInTmux(opts: RunInTmuxOptions): Promise<CliResult> {
247247
}
248248

249249
// Stream to Linear
250-
const activity = mapEvent(event);
251-
if (activity) {
250+
const activities = mapEvent(event);
251+
for (const activity of activities) {
252252
if (linearApi && agentSessionId) {
253253
linearApi.emitActivity(agentSessionId, activity).catch((err) => {
254254
logger.warn(`Failed to emit tmux activity: ${err}`);

src/tools/claude-tool.ts

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -26,17 +26,18 @@ const CLAUDE_BIN = "claude";
2626
* Claude event types:
2727
* system(init) → assistant (text|tool_use) → user (tool_result) → result
2828
*/
29-
function mapClaudeEventToActivity(event: any): ActivityContent | null {
29+
function mapClaudeEventToActivity(event: any): ActivityContent[] {
3030
const type = event?.type;
3131

32-
// Assistant message — text response or tool use
32+
// Assistant message — text response and/or tool use (emit all blocks)
3333
if (type === "assistant") {
3434
const content = event.message?.content;
35-
if (!Array.isArray(content)) return null;
35+
if (!Array.isArray(content)) return [];
3636

37+
const activities: ActivityContent[] = [];
3738
for (const block of content) {
3839
if (block.type === "text" && block.text) {
39-
return { type: "thought", body: block.text.slice(0, 1000) };
40+
activities.push({ type: "thought", body: block.text.slice(0, 1000) });
4041
}
4142
if (block.type === "tool_use") {
4243
const toolName = block.name ?? "tool";
@@ -54,30 +55,31 @@ function mapClaudeEventToActivity(event: any): ActivityContent | null {
5455
} else {
5556
paramSummary = JSON.stringify(input).slice(0, 500);
5657
}
57-
return { type: "action", action: `Running ${toolName}`, parameter: paramSummary };
58+
activities.push({ type: "action", action: `Running ${toolName}`, parameter: paramSummary });
5859
}
5960
}
60-
return null;
61+
return activities;
6162
}
6263

6364
// Tool result
6465
if (type === "user") {
6566
const content = event.message?.content;
66-
if (!Array.isArray(content)) return null;
67+
if (!Array.isArray(content)) return [];
6768

69+
const activities: ActivityContent[] = [];
6870
for (const block of content) {
6971
if (block.type === "tool_result") {
7072
const output = typeof block.content === "string" ? block.content : "";
7173
const truncated = output.length > 1000 ? output.slice(0, 1000) + "..." : output;
7274
const isError = block.is_error === true;
73-
return {
75+
activities.push({
7476
type: "action",
7577
action: isError ? "Tool error" : "Tool result",
7678
parameter: truncated || "(no output)",
77-
};
79+
});
7880
}
7981
}
80-
return null;
82+
return activities;
8183
}
8284

8385
// Final result
@@ -92,10 +94,10 @@ function mapClaudeEventToActivity(event: any): ActivityContent | null {
9294
const output = usage.output_tokens ?? 0;
9395
parts.push(`${input} in / ${output} out tokens`);
9496
}
95-
return { type: "thought", body: parts.join(" — ") };
97+
return [{ type: "thought", body: parts.join(" — ") }];
9698
}
9799

98-
return null;
100+
return [];
99101
}
100102

101103
/**
@@ -299,9 +301,9 @@ export async function runClaude(
299301
// (it duplicates the last assistant text message)
300302
}
301303

302-
// Stream activity to Linear + session progress
303-
const activity = mapClaudeEventToActivity(event);
304-
if (activity) {
304+
// Stream activities to Linear + session progress
305+
const activities = mapClaudeEventToActivity(event);
306+
for (const activity of activities) {
305307
if (linearApi && agentSessionId) {
306308
linearApi.emitActivity(agentSessionId, activity).catch((err) => {
307309
api.logger.warn(`Failed to emit Claude activity: ${err}`);

src/tools/codex-tool.ts

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -23,30 +23,30 @@ const CODEX_BIN = "codex";
2323
/**
2424
* Parse a JSONL line from `codex exec --json` and map it to a Linear activity.
2525
*/
26-
function mapCodexEventToActivity(event: any): ActivityContent | null {
26+
function mapCodexEventToActivity(event: any): ActivityContent[] {
2727
const eventType = event?.type;
2828
const item = event?.item;
2929

3030
if (item?.type === "reasoning") {
3131
const text = item.text ?? "";
32-
return { type: "thought", body: text ? text.slice(0, 500) : "Reasoning..." };
32+
return [{ type: "thought", body: text ? text.slice(0, 500) : "Reasoning..." }];
3333
}
3434

3535
if (
3636
(eventType === "item.completed" || eventType === "item.started") &&
3737
(item?.type === "agent_message" || item?.type === "message")
3838
) {
3939
const text = item.text ?? item.content ?? "";
40-
if (text) return { type: "thought", body: text.slice(0, 1000) };
41-
return null;
40+
if (text) return [{ type: "thought", body: text.slice(0, 1000) }];
41+
return [];
4242
}
4343

4444
if (eventType === "item.started" && item?.type === "command_execution") {
4545
const cmd = item.command ?? "unknown";
4646
const cleaned = typeof cmd === "string"
4747
? cmd.replace(/^\/usr\/bin\/\w+ -lc ['"]?/, "").replace(/['"]?$/, "")
4848
: JSON.stringify(cmd);
49-
return { type: "action", action: "Running", parameter: cleaned.slice(0, 200) };
49+
return [{ type: "action", action: "Running", parameter: cleaned.slice(0, 200) }];
5050
}
5151

5252
if (eventType === "item.completed" && item?.type === "command_execution") {
@@ -57,19 +57,19 @@ function mapCodexEventToActivity(event: any): ActivityContent | null {
5757
? cmd.replace(/^\/usr\/bin\/\w+ -lc ['"]?/, "").replace(/['"]?$/, "")
5858
: JSON.stringify(cmd);
5959
const truncated = output.length > 1000 ? output.slice(0, 1000) + "..." : output;
60-
return {
60+
return [{
6161
type: "action",
6262
action: `${cleaned.slice(0, 150)}`,
6363
parameter: `exit ${exitCode}`,
6464
result: truncated || undefined,
65-
};
65+
}];
6666
}
6767

6868
if (eventType === "item.completed" && item?.type === "file_changes") {
6969
const files = item.files ?? [];
7070
const fileList = Array.isArray(files) ? files.join(", ") : String(files);
7171
const preview = (item.diff ?? item.content ?? "").slice(0, 500) || undefined;
72-
return { type: "action", action: "Modified files", parameter: fileList || "unknown files", result: preview };
72+
return [{ type: "action", action: "Modified files", parameter: fileList || "unknown files", result: preview }];
7373
}
7474

7575
if (eventType === "turn.completed") {
@@ -78,12 +78,12 @@ function mapCodexEventToActivity(event: any): ActivityContent | null {
7878
const input = usage.input_tokens ?? 0;
7979
const cached = usage.cached_input_tokens ?? 0;
8080
const output = usage.output_tokens ?? 0;
81-
return { type: "thought", body: `Codex turn complete (${input} in / ${cached} cached / ${output} out tokens)` };
81+
return [{ type: "thought", body: `Codex turn complete (${input} in / ${cached} cached / ${output} out tokens)` }];
8282
}
83-
return { type: "thought", body: "Codex turn complete" };
83+
return [{ type: "thought", body: "Codex turn complete" }];
8484
}
8585

86-
return null;
86+
return [];
8787
}
8888

8989
/**
@@ -248,8 +248,8 @@ export async function runCodex(
248248
collectedCommands.push(`\`${cleanCmd}\` → exit ${exitCode}${truncOutput ? "\n```\n" + truncOutput + "\n```" : ""}`);
249249
}
250250

251-
const activity = mapCodexEventToActivity(event);
252-
if (activity) {
251+
const activities = mapCodexEventToActivity(event);
252+
for (const activity of activities) {
253253
if (linearApi && agentSessionId) {
254254
linearApi.emitActivity(agentSessionId, activity).catch((err) => {
255255
api.logger.warn(`Failed to emit Codex activity: ${err}`);

src/tools/gemini-tool.ts

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,14 @@ const GEMINI_BIN = "gemini";
2626
* Gemini event types:
2727
* init → message(user) → message(assistant) → tool_use → tool_result → result
2828
*/
29-
function mapGeminiEventToActivity(event: any): ActivityContent | null {
29+
function mapGeminiEventToActivity(event: any): ActivityContent[] {
3030
const type = event?.type;
3131

3232
// Assistant message (delta text)
3333
if (type === "message" && event.role === "assistant") {
3434
const text = event.content;
35-
if (text) return { type: "thought", body: text.slice(0, 1000) };
36-
return null;
35+
if (text) return [{ type: "thought", body: text.slice(0, 1000) }];
36+
return [];
3737
}
3838

3939
// Tool use — running a command or tool
@@ -50,19 +50,19 @@ function mapGeminiEventToActivity(event: any): ActivityContent | null {
5050
} else {
5151
paramSummary = JSON.stringify(params).slice(0, 500);
5252
}
53-
return { type: "action", action: `Running ${toolName}`, parameter: paramSummary };
53+
return [{ type: "action", action: `Running ${toolName}`, parameter: paramSummary }];
5454
}
5555

5656
// Tool result
5757
if (type === "tool_result") {
5858
const status = event.status ?? "unknown";
5959
const output = event.output ?? "";
6060
const truncated = output.length > 1000 ? output.slice(0, 1000) + "..." : output;
61-
return {
61+
return [{
6262
type: "action",
6363
action: `Tool ${status}`,
6464
parameter: truncated || "(no output)",
65-
};
65+
}];
6666
}
6767

6868
// Final result
@@ -74,10 +74,10 @@ function mapGeminiEventToActivity(event: any): ActivityContent | null {
7474
if (stats.total_tokens) parts.push(`${stats.total_tokens} tokens`);
7575
if (stats.tool_calls) parts.push(`${stats.tool_calls} tool calls`);
7676
}
77-
return { type: "thought", body: parts.join(" — ") };
77+
return [{ type: "thought", body: parts.join(" — ") }];
7878
}
7979

80-
return null;
80+
return [];
8181
}
8282

8383
/**
@@ -244,9 +244,9 @@ export async function runGemini(
244244
}
245245
}
246246

247-
// Stream activity to Linear + session progress
248-
const activity = mapGeminiEventToActivity(event);
249-
if (activity) {
247+
// Stream activities to Linear + session progress
248+
const activities = mapGeminiEventToActivity(event);
249+
for (const activity of activities) {
250250
if (linearApi && agentSessionId) {
251251
linearApi.emitActivity(agentSessionId, activity).catch((err) => {
252252
api.logger.warn(`Failed to emit Gemini activity: ${err}`);

0 commit comments

Comments
 (0)