@syengup/friday-channel-next 0.1.40 → 1.0.1

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.
@@ -33,6 +33,27 @@ export function resetThinkingStreamAccumStateForTest(): void {
33
33
  lastThinkingTextByRun.clear();
34
34
  }
35
35
 
36
+ /**
37
+ * Runs backed by the OpenClaw Codex app-server backend (model api `openai-chatgpt-responses`).
38
+ * They emit their activity under a `codex_app_server.*` stream namespace and — unlike the embedded
39
+ * runner — do NOT put reasoning text on the agent-event bus (`stream: "thinking"`); that text only
40
+ * arrives via the dispatch `onReasoningStream` callback. Likewise exec stdout never reaches the
41
+ * `command_output` stream. We mark a run as Codex the first time we see any `codex_app_server.*`
42
+ * frame so the message handler / tool hooks know to synthesize the missing `thinking` /
43
+ * `command_output` events for it (and ONLY for it — embedded runs already get both via the bus).
44
+ */
45
+ const codexRunIds = new Set<string>();
46
+
47
+ /** True once a `codex_app_server.*` frame has been seen for this run. */
48
+ export function isCodexRun(runId: string): boolean {
49
+ return codexRunIds.has(runId);
50
+ }
51
+
52
+ /** Vitest-only */
53
+ export function resetCodexRunTrackingForTest(): void {
54
+ codexRunIds.clear();
55
+ }
56
+
36
57
  /**
37
58
  * OpenClaw `runId` → device UUID (uppercase).
38
59
  * When `lifecycle.end` / `error` is emitted, the gateway may call `clearAgentRunContext` before this extension's
@@ -346,6 +367,37 @@ export function forwardAgentEventRaw(evt: ForwardAgentEventArgs): void {
346
367
 
347
368
  openClawRunIdToDeviceId.set(evt.runId, deviceIdRaw.toUpperCase());
348
369
 
370
+ // Flag Codex app-server runs so the message handler / tool hooks synthesize the `thinking` /
371
+ // `command_output` events that this backend never emits on the bus (see `isCodexRun`).
372
+ if (typeof evt.stream === "string" && evt.stream.startsWith("codex_app_server")) {
373
+ codexRunIds.add(evt.runId);
374
+ }
375
+
376
+ // Codex app-server reasoning: newer OpenClaw cores stopped invoking the dispatch
377
+ // `onReasoningStream` callback (the A2 path in messages.ts) and instead stream the
378
+ // reasoning summary on the agent-event bus as `stream:"item" kind:"preamble"` with a
379
+ // cumulative `progressText` (source "codex-app-server"). The Friday app only renders
380
+ // `stream:"thinking"`, so translate it here — synthesize a thinking event reusing the
381
+ // cumulative→delta rewrite below. The raw preamble item is still forwarded but the app
382
+ // ignores unknown item kinds. (The onReasoningStream callback stays as a harmless
383
+ // fallback for cores that still fire it.)
384
+ if (
385
+ evt.stream === "item" &&
386
+ evt.data.kind === "preamble" &&
387
+ evt.data.source === "codex-app-server"
388
+ ) {
389
+ codexRunIds.add(evt.runId);
390
+ const reasoningText = typeof evt.data.progressText === "string" ? evt.data.progressText : "";
391
+ if (reasoningText) {
392
+ forwardAgentEventRaw({
393
+ runId: evt.runId,
394
+ stream: "thinking",
395
+ data: { text: reasoningText },
396
+ sessionKey: evt.sessionKey ?? sk,
397
+ });
398
+ }
399
+ }
400
+
349
401
  // Register sessionKey → runId so we can resolve parentRunId
350
402
  if (sk && evt.stream === "lifecycle" && evt.data.phase === "start") {
351
403
  registerSessionKeyForRun(sk, evt.runId);
@@ -442,8 +494,7 @@ export function forwardAgentEventRaw(evt: ForwardAgentEventArgs): void {
442
494
  if (evt.stream === "lifecycle" && evt.data.phase === "start") {
443
495
  const announced = parseAnnounceRunId(evt.runId);
444
496
  if (announced) {
445
- const entry =
446
- lookupByChildSessionKey(announced.childSessionKey) ?? lookupByRunId(evt.runId);
497
+ const entry = lookupByChildSessionKey(announced.childSessionKey) ?? lookupByRunId(evt.runId);
447
498
  sseEmitter.broadcast(
448
499
  {
449
500
  type: "subagent",
@@ -500,6 +551,7 @@ export function forwardAgentEventRaw(evt: ForwardAgentEventArgs): void {
500
551
  }
501
552
  if (phase === "end" || phase === "error") {
502
553
  lastThinkingTextByRun.delete(evt.runId);
554
+ codexRunIds.delete(evt.runId);
503
555
  }
504
556
  }
505
557
 
@@ -0,0 +1,61 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import { resolveApprovalOverGateway } from "openclaw/plugin-sdk/approval-gateway-runtime";
3
+ import { readJsonBody } from "../middleware/body.js";
4
+ import { extractBearerToken } from "../middleware/auth.js";
5
+ import { getHostOpenClawConfigSnapshot } from "../../host-config.js";
6
+ import { getFridayNextRuntime } from "../../runtime.js";
7
+ import { createFridayNextLogger } from "../../logging.js";
8
+
9
+ const VALID_DECISIONS = new Set(["allow-once", "allow-always", "deny"]);
10
+
11
+ /**
12
+ * POST /friday-next/approvals/{approvalId}
13
+ * Body: { decision: "allow-once" | "allow-always" | "deny", deviceId?: string }
14
+ *
15
+ * Submits the app user's decision for a pending exec/plugin approval back to the gateway. The bearer
16
+ * token gates auth (the device owner is the approver); the gateway then resumes / aborts the run.
17
+ */
18
+ export async function handleApprovalDecision(
19
+ req: IncomingMessage,
20
+ res: ServerResponse,
21
+ approvalId: string,
22
+ ): Promise<boolean> {
23
+ const log = createFridayNextLogger("approvals");
24
+ const json = (status: number, body: Record<string, unknown>) => {
25
+ res.statusCode = status;
26
+ res.setHeader("Content-Type", "application/json");
27
+ res.end(JSON.stringify(body));
28
+ return true;
29
+ };
30
+
31
+ if (req.method !== "POST") return json(405, { error: "Method Not Allowed" });
32
+ if (!extractBearerToken(req)) return json(401, { error: "Unauthorized: bearer token mismatch" });
33
+ if (!approvalId.trim()) return json(400, { error: "Missing approvalId" });
34
+
35
+ const body = await readJsonBody(req);
36
+ if (!body) return json(400, { error: "Invalid JSON body" });
37
+
38
+ const decision = typeof body.decision === "string" ? body.decision.trim() : "";
39
+ if (!VALID_DECISIONS.has(decision)) {
40
+ return json(400, { error: "decision must be allow-once | allow-always | deny" });
41
+ }
42
+ const deviceId = typeof body.deviceId === "string" ? body.deviceId.trim().toUpperCase() : "";
43
+
44
+ const cfg = getHostOpenClawConfigSnapshot(getFridayNextRuntime().config);
45
+ try {
46
+ await resolveApprovalOverGateway({
47
+ cfg: cfg as Parameters<typeof resolveApprovalOverGateway>[0]["cfg"],
48
+ approvalId: approvalId.trim(),
49
+ decision: decision as "allow-once" | "allow-always" | "deny",
50
+ senderId: deviceId || null,
51
+ allowPluginFallback: true,
52
+ clientDisplayName: deviceId ? `Friday Next (${deviceId})` : "Friday Next",
53
+ });
54
+ } catch (err) {
55
+ log.error(`resolveApprovalOverGateway failed: ${err instanceof Error ? err.message : String(err)}`);
56
+ return json(502, { error: "Approval resolution failed", detail: String(err) });
57
+ }
58
+
59
+ log.info(`approval ${approvalId} resolved decision=${decision} device=${deviceId || "(none)"}`);
60
+ return json(200, { ok: true, approvalId: approvalId.trim(), decision });
61
+ }
@@ -37,7 +37,11 @@ import {
37
37
  import { sseEmitter } from "../../sse/emitter.js";
38
38
  import { extractBearerToken } from "../middleware/auth.js";
39
39
  import { readJsonBody } from "../middleware/body.js";
40
- import { registerFridaySessionDeviceMapping } from "../../friday-session.js";
40
+ import {
41
+ forwardAgentEventRaw,
42
+ isCodexRun,
43
+ registerFridaySessionDeviceMapping,
44
+ } from "../../friday-session.js";
41
45
  import { touchFridayInbound } from "../../friday-inbound-stats.js";
42
46
  import {
43
47
  fridayAttachmentLookupKey,
@@ -631,6 +635,12 @@ export async function handleMessages(req: IncomingMessage, res: ServerResponse):
631
635
  runId,
632
636
  suppressTyping: true,
633
637
  disableBlockStreaming: true,
638
+ // A1: feed the chosen thinking level into the run as a one-shot override so the model
639
+ // request asks for a reasoning summary. The session-stored `thinkingLevel` alone is NOT
640
+ // honored by the reply dispatch; `thinkingLevelOverride` has top priority in OpenClaw's
641
+ // resolution chain (get-reply-directives). Required for Codex (openai-chatgpt-responses)
642
+ // to emit any reasoning at all.
643
+ ...(thinkingLevel ? { thinkingLevelOverride: thinkingLevel } : {}),
634
644
  onModelSelected: (sel: any) => {
635
645
  const name = typeof sel.model === "string" ? sel.model.trim() : "";
636
646
  if (name) {
@@ -646,6 +656,18 @@ export async function handleMessages(req: IncomingMessage, res: ServerResponse):
646
656
  : undefined;
647
657
  const text = typeof rawText === "string" ? rawText : "";
648
658
  log("REASONING_STREAM", normalizedDeviceId, runId, `textLen=${text.length}`);
659
+ // A2: the embedded runner already emits `stream: "thinking"` on the agent-event bus, so
660
+ // forwarding here would double it. The Codex app-server backend does NOT — reasoning text
661
+ // only arrives via this callback (cumulative snapshot). Forward it as a thinking event
662
+ // (reusing forwardAgentEventRaw's cumulative→delta rewrite) ONLY for Codex runs.
663
+ if (text && isCodexRun(runId)) {
664
+ forwardAgentEventRaw({
665
+ runId,
666
+ stream: "thinking",
667
+ data: { text },
668
+ sessionKey: baseSessionKey,
669
+ });
670
+ }
649
671
  },
650
672
  onReasoningEnd: async () => {
651
673
  log("REASONING_STREAM_END", normalizedDeviceId, runId);
@@ -13,6 +13,7 @@ import { handleFilesDownload } from "./handlers/files-download.js";
13
13
  import { handleCancel } from "./handlers/cancel.js";
14
14
  import { handleDeviceApprove } from "./handlers/device-approve.js";
15
15
  import { handleNodesApprove } from "./handlers/nodes-approve.js";
16
+ import { handleApprovalDecision } from "./handlers/approvals.js";
16
17
  import { handleSessionsSettings } from "./handlers/sessions-settings.js";
17
18
  import { handleModelsList } from "./handlers/models-list.js";
18
19
  import { handleAgentsList } from "./handlers/agents-list.js";
@@ -76,6 +77,12 @@ async function handleFridayNextRoute(req: IncomingMessage, res: ServerResponse):
76
77
  return await handleNodesApprove(req, res);
77
78
  }
78
79
 
80
+ // Route: POST /friday-next/approvals/{approvalId} (submit exec/plugin approval decision)
81
+ if (req.method === "POST" && pathname.startsWith("/friday-next/approvals/")) {
82
+ const approvalId = decodeURIComponent(pathname.slice("/friday-next/approvals/".length));
83
+ return await handleApprovalDecision(req, res, approvalId);
84
+ }
85
+
79
86
  if (
80
87
  (req.method === "PUT" || req.method === "GET") &&
81
88
  pathname === "/friday-next/sessions/settings"
@@ -11,7 +11,8 @@ export type SseEventType =
11
11
  | "tool-hook"
12
12
  | "outbound"
13
13
  | "ping"
14
- | "subagent";
14
+ | "subagent"
15
+ | "approval";
15
16
 
16
17
  export interface SseEvent {
17
18
  type: SseEventType;
@@ -1,39 +0,0 @@
1
- export interface SelfHealthOptions {
2
- enabled: boolean;
3
- checkIntervalMs: number;
4
- selfHeal: boolean;
5
- }
6
- interface CheckResult {
7
- name: string;
8
- status: "ok" | "degraded" | "failed";
9
- detail: string;
10
- }
11
- interface RepairAction {
12
- component: string;
13
- action: string;
14
- result: "ok" | "failed" | "skipped";
15
- detail: string;
16
- }
17
- export interface SelfHealthReport {
18
- timestamp: number;
19
- checks: CheckResult[];
20
- repairs: RepairAction[];
21
- overallStatus: "ok" | "degraded" | "failed";
22
- }
23
- export declare class HealthCheckRunner {
24
- private timer;
25
- private options;
26
- constructor(options?: Partial<SelfHealthOptions>);
27
- updateOptions(opts: Partial<SelfHealthOptions>): void;
28
- start(_api: unknown): void;
29
- stop(): void;
30
- runCheck(): Promise<SelfHealthReport>;
31
- private checkConfig;
32
- private checkSseEmitter;
33
- private checkActiveRuns;
34
- private repairConfig;
35
- private repairSseEmitter;
36
- }
37
- export declare function getHealthCheckRunner(): HealthCheckRunner;
38
- export declare function resetHealthCheckRunnerForTest(): void;
39
- export {};
@@ -1,174 +0,0 @@
1
- import { resolveFridayNextConfig } from "../config.js";
2
- import { getHostOpenClawConfigSnapshot } from "../host-config.js";
3
- import { getFridayNextRuntime } from "../runtime.js";
4
- import { sseEmitter } from "../sse/emitter.js";
5
- import { getActiveRunCount } from "../agent/active-runs.js";
6
- import { createFridayNextLogger } from "../logging.js";
7
- const log = createFridayNextLogger("health-runner", "info");
8
- export class HealthCheckRunner {
9
- timer = null;
10
- options;
11
- constructor(options = {}) {
12
- this.options = {
13
- enabled: options.enabled ?? true,
14
- checkIntervalMs: options.checkIntervalMs ?? 60_000,
15
- selfHeal: options.selfHeal ?? true,
16
- };
17
- }
18
- updateOptions(opts) {
19
- Object.assign(this.options, opts);
20
- }
21
- start(_api) {
22
- this.stop();
23
- if (!this.options.enabled) {
24
- log.info("Self-health check disabled by config");
25
- return;
26
- }
27
- log.info(`Starting self-health checks every ${this.options.checkIntervalMs}ms`);
28
- this.timer = setInterval(() => {
29
- this.runCheck().catch((err) => {
30
- log.error(`Self-health check error: ${err instanceof Error ? err.message : String(err)}`);
31
- });
32
- }, this.options.checkIntervalMs);
33
- if (this.timer && typeof this.timer.unref === "function") {
34
- this.timer.unref();
35
- }
36
- }
37
- stop() {
38
- if (this.timer) {
39
- clearInterval(this.timer);
40
- this.timer = null;
41
- }
42
- }
43
- async runCheck() {
44
- const report = {
45
- timestamp: Date.now(),
46
- checks: [],
47
- repairs: [],
48
- overallStatus: "ok",
49
- };
50
- report.checks.push(this.checkConfig());
51
- report.checks.push(this.checkSseEmitter());
52
- report.checks.push(this.checkActiveRuns());
53
- if (this.options.selfHeal) {
54
- const configCheck = report.checks[0];
55
- if (configCheck.status === "failed") {
56
- report.repairs.push(await this.repairConfig());
57
- }
58
- const sseCheck = report.checks[1];
59
- if (sseCheck.status === "failed") {
60
- report.repairs.push(await this.repairSseEmitter());
61
- }
62
- }
63
- const statuses = report.checks.map((c) => c.status);
64
- if (statuses.includes("failed")) {
65
- report.overallStatus = "failed";
66
- }
67
- else if (statuses.includes("degraded")) {
68
- report.overallStatus = "degraded";
69
- }
70
- if (report.overallStatus !== "ok" || report.repairs.length > 0) {
71
- log.warn(`Self-health result: ${report.overallStatus}, ` +
72
- `checks=${report.checks.length}, repairs=${report.repairs.length}`);
73
- }
74
- return report;
75
- }
76
- checkConfig() {
77
- try {
78
- const runtime = getFridayNextRuntime();
79
- if (!runtime?.config) {
80
- return { name: "config", status: "failed", detail: "Runtime config loader not available" };
81
- }
82
- const cfg = resolveFridayNextConfig(getHostOpenClawConfigSnapshot(runtime.config));
83
- if (!cfg.authToken) {
84
- return { name: "config", status: "degraded", detail: "authToken is empty; all requests will 401" };
85
- }
86
- return { name: "config", status: "ok", detail: "Config resolved with authToken" };
87
- }
88
- catch (err) {
89
- return {
90
- name: "config",
91
- status: "failed",
92
- detail: `Config resolution error: ${err instanceof Error ? err.message : String(err)}`,
93
- };
94
- }
95
- }
96
- checkSseEmitter() {
97
- try {
98
- const connCount = sseEmitter.getConnectionCount();
99
- return {
100
- name: "sseEmitter",
101
- status: "ok",
102
- detail: `Active connections: ${connCount}`,
103
- };
104
- }
105
- catch (err) {
106
- return {
107
- name: "sseEmitter",
108
- status: "failed",
109
- detail: `Emitter check error: ${err instanceof Error ? err.message : String(err)}`,
110
- };
111
- }
112
- }
113
- checkActiveRuns() {
114
- try {
115
- const count = getActiveRunCount();
116
- return {
117
- name: "activeRuns",
118
- status: "ok",
119
- detail: `Active runs: ${count}`,
120
- };
121
- }
122
- catch (err) {
123
- return {
124
- name: "activeRuns",
125
- status: "failed",
126
- detail: `Active runs check error: ${err instanceof Error ? err.message : String(err)}`,
127
- };
128
- }
129
- }
130
- async repairConfig() {
131
- try {
132
- const runtime = getFridayNextRuntime();
133
- if (!runtime?.config) {
134
- return {
135
- component: "config",
136
- action: "re-resolve config",
137
- result: "failed",
138
- detail: "Runtime config loader not available",
139
- };
140
- }
141
- resolveFridayNextConfig(getHostOpenClawConfigSnapshot(runtime.config));
142
- return { component: "config", action: "re-resolve config", result: "ok", detail: "Config re-resolved" };
143
- }
144
- catch (err) {
145
- return {
146
- component: "config",
147
- action: "re-resolve config",
148
- result: "failed",
149
- detail: err instanceof Error ? err.message : String(err),
150
- };
151
- }
152
- }
153
- async repairSseEmitter() {
154
- return {
155
- component: "sseEmitter",
156
- action: "verify emitter",
157
- result: "ok",
158
- detail: "SSE emitter singleton is accessible",
159
- };
160
- }
161
- }
162
- let healthCheckRunner = null;
163
- export function getHealthCheckRunner() {
164
- if (!healthCheckRunner) {
165
- healthCheckRunner = new HealthCheckRunner();
166
- }
167
- return healthCheckRunner;
168
- }
169
- export function resetHealthCheckRunnerForTest() {
170
- if (healthCheckRunner) {
171
- healthCheckRunner.stop();
172
- healthCheckRunner = null;
173
- }
174
- }
@@ -1,2 +0,0 @@
1
- import type { IncomingMessage, ServerResponse } from "node:http";
2
- export declare function handleSessionsDelete(req: IncomingMessage, res: ServerResponse): Promise<boolean>;
@@ -1,49 +0,0 @@
1
- import { deleteFridaySession, toSessionStoreKey } from "../../session/session-manager.js";
2
- import { getActiveRunIds } from "../../agent/active-runs.js";
3
- import { abortRun } from "../../agent/abort-run.js";
4
- import { getRunRoute } from "../../run-metadata.js";
5
- import { sseEmitter } from "../../sse/emitter.js";
6
- import { readJsonBody } from "../middleware/body.js";
7
- import { extractBearerToken } from "../middleware/auth.js";
8
- async function cancelActiveRunsForSession(sessionKey) {
9
- const storeKey = toSessionStoreKey(sessionKey);
10
- const cancelled = [];
11
- for (const runId of getActiveRunIds()) {
12
- const route = getRunRoute(runId);
13
- if (route?.sessionKey === storeKey) {
14
- await abortRun(runId);
15
- sseEmitter.untrackRun(runId);
16
- cancelled.push(runId);
17
- }
18
- }
19
- return cancelled;
20
- }
21
- export async function handleSessionsDelete(req, res) {
22
- if (req.method !== "DELETE") {
23
- res.statusCode = 405;
24
- res.setHeader("Content-Type", "application/json");
25
- res.end(JSON.stringify({ error: "Method Not Allowed" }));
26
- return true;
27
- }
28
- const token = extractBearerToken(req);
29
- if (!token) {
30
- res.statusCode = 401;
31
- res.setHeader("Content-Type", "application/json");
32
- res.end(JSON.stringify({ error: "Unauthorized: bearer token mismatch" }));
33
- return true;
34
- }
35
- const body = await readJsonBody(req);
36
- const sessionKey = typeof body?.sessionKey === "string" ? body.sessionKey.trim() : "";
37
- if (!sessionKey) {
38
- res.statusCode = 400;
39
- res.setHeader("Content-Type", "application/json");
40
- res.end(JSON.stringify({ error: "Missing required field: sessionKey" }));
41
- return true;
42
- }
43
- const cancelledRuns = await cancelActiveRunsForSession(sessionKey);
44
- const result = deleteFridaySession(sessionKey);
45
- res.statusCode = 200;
46
- res.setHeader("Content-Type", "application/json");
47
- res.end(JSON.stringify({ ok: true, ...result, cancelledRuns }));
48
- return true;
49
- }