@syengup/friday-channel-next 1.0.3 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -16,8 +16,6 @@ import { ensureCodexReasoningSummary } from "./src/codex-reasoning-config.js";
16
16
  const hookLogger = createFridayNextLogger("hook");
17
17
  export { fridayNextChannelPlugin } from "./src/channel.js";
18
18
  export { setFridayNextRuntime } from "./src/runtime.js";
19
- /** `api.on` returns void — register tool hooks at most once per process. */
20
- let fridayNextToolHooksRegistered = false;
21
19
  let disposeAgentEventListener = null;
22
20
  /**
23
21
  * Track the last `api` instance on which HTTP routes were registered.
@@ -142,10 +140,17 @@ export default defineChannelPluginEntry({
142
140
  total: event.usage?.total,
143
141
  }, event.model, event.provider);
144
142
  });
145
- if (fridayNextToolHooksRegistered) {
143
+ // Tool hooks (subagent_delivery_target / before_tool_call / after_tool_call) must follow the
144
+ // SAME re-registration discipline as the HTTP routes and onAgentEvent listener above. When the
145
+ // health-monitor restarts the plugin, `registerFull` receives a fresh `api` and the host
146
+ // dispatches hooks on THAT api; hooks left bound to the first api go silent for the rest of the
147
+ // process. The old one-time boolean guard bound them to whichever api arrived first — which is
148
+ // exactly why Codex `command_output` (the A3 after_tool_call stdout synthesis) fired on some
149
+ // gateway processes and not others. Re-register on every genuinely-new api; skip only a repeat
150
+ // call with the same api (matching the `!sameApi` gate the routes use).
151
+ if (sameApi) {
146
152
  return;
147
153
  }
148
- fridayNextToolHooksRegistered = true;
149
154
  // Make Codex (ChatGPT/OAuth) models emit reasoning summary text so the app can stream
150
155
  // "thinking". OpenClaw never sets this; we assert it on the plugin side. Best-effort.
151
156
  ensureCodexReasoningSummary((msg) => hookLogger.info(msg));
@@ -346,12 +346,18 @@ export function forwardAgentEventRaw(evt) {
346
346
  // Codex app-server projects every tool/command call onto BOTH the standard `tool` stream
347
347
  // (carrying args + the real result) AND a redundant `item` event (kind:"tool"/"command"),
348
348
  // and core flags that item `suppressChannelProgress: true` ("do not surface in channel
349
- // progress"). Forwarding the suppressed item anyway double-renders every non-exec tool in
350
- // the app — the `tool`-stream row plus a second `item kind:tool` row, with the result landing
351
- // only on the first. Honor the flag and drop suppressed items; the `tool` stream (and, for
352
- // exec, the synthesized `command_output`) already carries everything the app renders. Codex
353
- // reasoning items (preamble/analysis) are NOT suppressed, so this never touches thinking.
354
- if (evt.stream === "item" && evt.data.suppressChannelProgress === true) {
349
+ // progress"). Forwarding the suppressed *tool* item double-renders every non-exec tool in the
350
+ // app — the `tool`-stream row plus a second `item kind:tool` row, with the result landing only
351
+ // on the first. So drop ONLY suppressed `kind:"tool"` items.
352
+ //
353
+ // BUT keep suppressed `kind:"command"`/`"process"` items: the `tool` stream's exec result is
354
+ // just {exitCode,duration} (no stdout), and the app bootstraps its command-terminal row from
355
+ // the `item kind:command` event — that row is what the synthesized `command_output` end event
356
+ // then attaches the real stdout to. Dropping it left exec tools with a command line and no
357
+ // output in the trace. (Reasoning items preamble/analysis are never suppressed.)
358
+ if (evt.stream === "item" &&
359
+ evt.data.suppressChannelProgress === true &&
360
+ evt.data.kind === "tool") {
355
361
  return;
356
362
  }
357
363
  // Register sessionKey → runId so we can resolve parentRunId
@@ -91,6 +91,21 @@ function parseContent(content) {
91
91
  }
92
92
  break;
93
93
  }
94
+ case "toolResult":
95
+ case "tool_result": {
96
+ // Codex (app-server backend) persists an exec/bash tool's STDOUT in a content
97
+ // block whose `type` is "toolResult" (not "text") — the output lives in the
98
+ // block's `text`/`content`. The native/Anthropic path uses plain "text" blocks,
99
+ // so this case was never hit and the command output was silently dropped from
100
+ // history (`parsed.text` stayed ""), leaving the app's trace tool row with no
101
+ // result. ControlUI shows it because its projection reads these blocks.
102
+ const t = readString(block.text) || readString(block.content);
103
+ if (t) {
104
+ textParts.push(t);
105
+ out.images.push(...extractMediaMarkers(t));
106
+ }
107
+ break;
108
+ }
94
109
  case "thinking": {
95
110
  const t = readString(block.thinking);
96
111
  if (t)
package/index.ts CHANGED
@@ -30,8 +30,6 @@ const hookLogger = createFridayNextLogger("hook");
30
30
  export { fridayNextChannelPlugin } from "./src/channel.js";
31
31
  export { setFridayNextRuntime } from "./src/runtime.js";
32
32
 
33
- /** `api.on` returns void — register tool hooks at most once per process. */
34
- let fridayNextToolHooksRegistered = false;
35
33
  let disposeAgentEventListener: (() => void) | null = null;
36
34
  /**
37
35
  * Track the last `api` instance on which HTTP routes were registered.
@@ -163,10 +161,17 @@ export default defineChannelPluginEntry({
163
161
  );
164
162
  });
165
163
 
166
- if (fridayNextToolHooksRegistered) {
164
+ // Tool hooks (subagent_delivery_target / before_tool_call / after_tool_call) must follow the
165
+ // SAME re-registration discipline as the HTTP routes and onAgentEvent listener above. When the
166
+ // health-monitor restarts the plugin, `registerFull` receives a fresh `api` and the host
167
+ // dispatches hooks on THAT api; hooks left bound to the first api go silent for the rest of the
168
+ // process. The old one-time boolean guard bound them to whichever api arrived first — which is
169
+ // exactly why Codex `command_output` (the A3 after_tool_call stdout synthesis) fired on some
170
+ // gateway processes and not others. Re-register on every genuinely-new api; skip only a repeat
171
+ // call with the same api (matching the `!sameApi` gate the routes use).
172
+ if (sameApi) {
167
173
  return;
168
174
  }
169
- fridayNextToolHooksRegistered = true;
170
175
 
171
176
  // Make Codex (ChatGPT/OAuth) models emit reasoning summary text so the app can stream
172
177
  // "thinking". OpenClaw never sets this; we assert it on the plugin side. Best-effort.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syengup/friday-channel-next",
3
- "version": "1.0.3",
3
+ "version": "1.0.4",
4
4
  "description": "OpenClaw Friday Next Apple channel plugin",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -195,6 +195,27 @@ describe("forwardAgentEventRaw (thinking delta rewrite)", () => {
195
195
  expect(sseEmitter.broadcastToRun).not.toHaveBeenCalled();
196
196
  });
197
197
 
198
+ it("KEEPS suppressed item kind:command (exec row the command_output stdout attaches to)", () => {
199
+ // The `tool`-stream exec result is just {exitCode,duration}; the real stdout arrives on the
200
+ // synthesized `command_output` end event, which the app attaches to the command-terminal row
201
+ // bootstrapped from THIS `item kind:command`. Dropping it (as the old broad filter did) left
202
+ // exec tools with a command line and no output in the trace.
203
+ forwardAgentEventRaw({
204
+ runId,
205
+ seq: 1,
206
+ stream: "item",
207
+ sessionKey,
208
+ data: {
209
+ itemId: "call_exec1",
210
+ kind: "command",
211
+ phase: "start",
212
+ name: "bash",
213
+ suppressChannelProgress: true,
214
+ },
215
+ });
216
+ expect(sseEmitter.broadcastToRun).toHaveBeenCalledTimes(1);
217
+ });
218
+
198
219
  it("forwards item events that are not suppressed (e.g. reasoning analysis markers)", () => {
199
220
  forwardAgentEventRaw({
200
221
  runId,
@@ -401,12 +401,20 @@ export function forwardAgentEventRaw(evt: ForwardAgentEventArgs): void {
401
401
  // Codex app-server projects every tool/command call onto BOTH the standard `tool` stream
402
402
  // (carrying args + the real result) AND a redundant `item` event (kind:"tool"/"command"),
403
403
  // and core flags that item `suppressChannelProgress: true` ("do not surface in channel
404
- // progress"). Forwarding the suppressed item anyway double-renders every non-exec tool in
405
- // the app — the `tool`-stream row plus a second `item kind:tool` row, with the result landing
406
- // only on the first. Honor the flag and drop suppressed items; the `tool` stream (and, for
407
- // exec, the synthesized `command_output`) already carries everything the app renders. Codex
408
- // reasoning items (preamble/analysis) are NOT suppressed, so this never touches thinking.
409
- if (evt.stream === "item" && evt.data.suppressChannelProgress === true) {
404
+ // progress"). Forwarding the suppressed *tool* item double-renders every non-exec tool in the
405
+ // app — the `tool`-stream row plus a second `item kind:tool` row, with the result landing only
406
+ // on the first. So drop ONLY suppressed `kind:"tool"` items.
407
+ //
408
+ // BUT keep suppressed `kind:"command"`/`"process"` items: the `tool` stream's exec result is
409
+ // just {exitCode,duration} (no stdout), and the app bootstraps its command-terminal row from
410
+ // the `item kind:command` event — that row is what the synthesized `command_output` end event
411
+ // then attaches the real stdout to. Dropping it left exec tools with a command line and no
412
+ // output in the trace. (Reasoning items preamble/analysis are never suppressed.)
413
+ if (
414
+ evt.stream === "item" &&
415
+ evt.data.suppressChannelProgress === true &&
416
+ evt.data.kind === "tool"
417
+ ) {
410
418
  return;
411
419
  }
412
420
 
@@ -185,6 +185,33 @@ describe("normalizeHistoryMessage", () => {
185
185
  expect(plain?.mediaPaths).toBeUndefined();
186
186
  });
187
187
 
188
+ it("extracts Codex exec stdout from a `toolResult`-typed content block", () => {
189
+ // Codex (app-server) persists bash/exec stdout in a content block whose `type` is
190
+ // "toolResult" (not "text"); the native path only ever emits "text" blocks. Before the
191
+ // fix this fell through `default` and the command output was dropped (text="").
192
+ const out = normalizeHistoryMessage(
193
+ {
194
+ role: "toolResult",
195
+ toolCallId: "call_GSu3",
196
+ toolName: "bash",
197
+ content: [
198
+ {
199
+ type: "toolResult",
200
+ toolCallId: "call_GSu3",
201
+ name: "bash",
202
+ content: "Applications\nDesktop\nDocuments",
203
+ text: "Applications\nDesktop\nDocuments",
204
+ },
205
+ ],
206
+ ...meta("tr-1", 3),
207
+ },
208
+ 0,
209
+ );
210
+ expect(out?.role).toBe("toolResult");
211
+ expect(out?.toolResult?.text).toBe("Applications\nDesktop\nDocuments");
212
+ expect(out?.toolResult?.toolCallId).toBe("call_GSu3");
213
+ });
214
+
188
215
  it("flags compaction records via __openclaw.kind", () => {
189
216
  const out = normalizeHistoryMessage(
190
217
  {
@@ -158,6 +158,21 @@ function parseContent(content: unknown): ParsedContent {
158
158
  }
159
159
  break;
160
160
  }
161
+ case "toolResult":
162
+ case "tool_result": {
163
+ // Codex (app-server backend) persists an exec/bash tool's STDOUT in a content
164
+ // block whose `type` is "toolResult" (not "text") — the output lives in the
165
+ // block's `text`/`content`. The native/Anthropic path uses plain "text" blocks,
166
+ // so this case was never hit and the command output was silently dropped from
167
+ // history (`parsed.text` stayed ""), leaving the app's trace tool row with no
168
+ // result. ControlUI shows it because its projection reads these blocks.
169
+ const t = readString(block.text) || readString(block.content);
170
+ if (t) {
171
+ textParts.push(t);
172
+ out.images.push(...extractMediaMarkers(t));
173
+ }
174
+ break;
175
+ }
161
176
  case "thinking": {
162
177
  const t = readString(block.thinking);
163
178
  if (t) thinkingParts.push(t);