@syengup/friday-channel-next 0.1.39 → 1.0.0

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.
Files changed (43) hide show
  1. package/dist/index.js +59 -1
  2. package/dist/src/agent/subagent-registry.d.ts +4 -0
  3. package/dist/src/agent/subagent-registry.js +1 -1
  4. package/dist/src/approval/friday-approval-capability.d.ts +44 -0
  5. package/dist/src/approval/friday-approval-capability.js +174 -0
  6. package/dist/src/channel.js +22 -0
  7. package/dist/src/codex-reasoning-config.d.ts +11 -0
  8. package/dist/src/codex-reasoning-config.js +83 -0
  9. package/dist/src/friday-session.d.ts +4 -0
  10. package/dist/src/friday-session.js +59 -1
  11. package/dist/src/http/handlers/agents-list.js +5 -1
  12. package/dist/src/http/handlers/approvals.d.ts +9 -0
  13. package/dist/src/http/handlers/approvals.js +54 -0
  14. package/dist/src/http/handlers/messages.js +19 -1
  15. package/dist/src/http/server.js +6 -0
  16. package/dist/src/http 2/middleware/auth.d.ts +13 -0
  17. package/dist/src/http 2/middleware/auth.js +29 -0
  18. package/dist/src/http 2/middleware/body.d.ts +2 -0
  19. package/dist/src/http 2/middleware/body.js +24 -0
  20. package/dist/src/http 2/middleware/cors.d.ts +2 -0
  21. package/dist/src/http 2/middleware/cors.js +11 -0
  22. package/dist/src/sse/emitter.d.ts +1 -1
  23. package/index.ts +61 -0
  24. package/package.json +15 -14
  25. package/src/agent/subagent-registry.ts +3 -1
  26. package/src/approval/friday-approval-capability.test.ts +78 -0
  27. package/src/approval/friday-approval-capability.ts +227 -0
  28. package/src/channel.ts +25 -1
  29. package/src/codex-reasoning-config.test.ts +28 -0
  30. package/src/codex-reasoning-config.ts +82 -0
  31. package/src/e2e/subagent.e2e.test.ts +6 -0
  32. package/src/friday-session.forward-agent.test.ts +127 -0
  33. package/src/friday-session.ts +76 -1
  34. package/src/http/handlers/agents-list.test.ts +28 -0
  35. package/src/http/handlers/agents-list.ts +5 -1
  36. package/src/http/handlers/approvals.ts +61 -0
  37. package/src/http/handlers/messages.ts +23 -1
  38. package/src/http/server.ts +7 -0
  39. package/src/sse/emitter.ts +2 -1
  40. package/dist/src/health/self-health.d.ts +0 -39
  41. package/dist/src/health/self-health.js +0 -174
  42. package/dist/src/http/handlers/sessions-delete.d.ts +0 -2
  43. package/dist/src/http/handlers/sessions-delete.js +0 -49
@@ -71,6 +71,34 @@ describe("handleAgentsList", () => {
71
71
  expect(body.agents).toEqual([{ id: "main", isDefault: true }]);
72
72
  });
73
73
 
74
+ it("resolves the implicit main name from IDENTITY.md when no agents.list exists", async () => {
75
+ const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "friday-identity-main-"));
76
+ fs.writeFileSync(
77
+ path.join(workspace, "IDENTITY.md"),
78
+ "# IDENTITY.md\n\n- **Name:** F.R.I.D.A.Y\n- **Emoji:** 🌿\n",
79
+ );
80
+ try {
81
+ setFridayAgentForwardRuntime({
82
+ runtime: {
83
+ agent: {
84
+ session: { resolveStorePath: () => "", loadSessionStore: () => ({}) },
85
+ resolveAgentWorkspaceDir: () => workspace,
86
+ },
87
+ config: { current: () => ({ agents: { defaults: {} } }) },
88
+ },
89
+ } as any);
90
+
91
+ const res = new MockRes();
92
+ await handleAgentsList(makeReq(AUTH), res as any);
93
+
94
+ const body = JSON.parse(res.body);
95
+ expect(body.defaultAgentId).toBe("main");
96
+ expect(body.agents).toEqual([{ id: "main", name: "F.R.I.D.A.Y", isDefault: true }]);
97
+ } finally {
98
+ fs.rmSync(workspace, { recursive: true, force: true });
99
+ }
100
+ });
101
+
74
102
  it("lists configured agents with normalized ids and resolved fields", async () => {
75
103
  setConfig({
76
104
  agents: {
@@ -106,8 +106,12 @@ function resolveConfiguredAgents(): ResolvedAgents {
106
106
  const list = agents?.list as Array<Record<string, unknown>> | undefined;
107
107
 
108
108
  if (!Array.isArray(list) || list.length === 0) {
109
+ // Implicit `main` agent (no `agents.list`): config carries no name, so fall
110
+ // back to the workspace IDENTITY.md `Name` — the same source ControlUI and
111
+ // the list branch below use — instead of letting the app show the raw id.
112
+ const name = readWorkspaceIdentityName(rt, cfg, DEFAULT_AGENT_ID);
109
113
  return {
110
- agents: [{ id: DEFAULT_AGENT_ID, isDefault: true }],
114
+ agents: [{ id: DEFAULT_AGENT_ID, isDefault: true, ...(name ? { name } : {}) }],
111
115
  defaultAgentId: DEFAULT_AGENT_ID,
112
116
  };
113
117
  }
@@ -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
- }