@syengup/friday-channel-next 0.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.
Files changed (67) hide show
  1. package/README.md +35 -0
  2. package/index.ts +191 -0
  3. package/install.mjs +158 -0
  4. package/install.sh +118 -0
  5. package/openclaw.plugin.json +53 -0
  6. package/package.json +65 -0
  7. package/src/agent/abort-run.ts +10 -0
  8. package/src/agent/active-runs.ts +26 -0
  9. package/src/agent/dispatch-bridge.ts +18 -0
  10. package/src/agent/media-bridge.ts +23 -0
  11. package/src/agent-forward-runtime.ts +30 -0
  12. package/src/agent-run-context-bridge.ts +32 -0
  13. package/src/channel-actions.ts +129 -0
  14. package/src/channel.ts +284 -0
  15. package/src/collect-message-media-paths.ts +132 -0
  16. package/src/config.test.ts +33 -0
  17. package/src/config.ts +64 -0
  18. package/src/e2e/attachments-inbound.e2e.test.ts +43 -0
  19. package/src/e2e/attachments-outbound.e2e.test.ts +43 -0
  20. package/src/e2e/cancel-reconnect-errors.e2e.test.ts +56 -0
  21. package/src/e2e/connect-and-connected.e2e.test.ts +44 -0
  22. package/src/e2e/offline-replay.e2e.test.ts +43 -0
  23. package/src/e2e/send-text.e2e.test.ts +73 -0
  24. package/src/e2e/slash-commands.e2e.test.ts +33 -0
  25. package/src/e2e/status-cors-auth.e2e.test.ts +41 -0
  26. package/src/e2e/tool-lifecycle.e2e.test.ts +49 -0
  27. package/src/friday-inbound-stats.ts +10 -0
  28. package/src/friday-session.forward-agent.test.ts +270 -0
  29. package/src/friday-session.ts +327 -0
  30. package/src/host-config.ts +20 -0
  31. package/src/http/handlers/cancel.test.ts +70 -0
  32. package/src/http/handlers/cancel.ts +35 -0
  33. package/src/http/handlers/files-download.ts +239 -0
  34. package/src/http/handlers/files-upload.ts +166 -0
  35. package/src/http/handlers/files.ts +335 -0
  36. package/src/http/handlers/messages.test.ts +119 -0
  37. package/src/http/handlers/messages.ts +555 -0
  38. package/src/http/handlers/models-list.ts +126 -0
  39. package/src/http/handlers/sessions-delete.ts +59 -0
  40. package/src/http/handlers/sessions-settings.ts +90 -0
  41. package/src/http/handlers/sse.test.ts +71 -0
  42. package/src/http/handlers/sse.ts +84 -0
  43. package/src/http/handlers/status.test.ts +52 -0
  44. package/src/http/handlers/status.ts +33 -0
  45. package/src/http/middleware/auth.test.ts +46 -0
  46. package/src/http/middleware/auth.ts +31 -0
  47. package/src/http/middleware/body.test.ts +27 -0
  48. package/src/http/middleware/body.ts +28 -0
  49. package/src/http/middleware/cors.test.ts +40 -0
  50. package/src/http/middleware/cors.ts +12 -0
  51. package/src/http/server.ts +106 -0
  52. package/src/logging.ts +27 -0
  53. package/src/openclaw.d.ts +32 -0
  54. package/src/run-metadata.ts +180 -0
  55. package/src/runtime.ts +14 -0
  56. package/src/session/session-manager.ts +230 -0
  57. package/src/session-usage-snapshot.ts +80 -0
  58. package/src/sse/emitter.test.ts +85 -0
  59. package/src/sse/emitter.ts +249 -0
  60. package/src/sse/frame-format.test.ts +56 -0
  61. package/src/sse/offline-queue.test.ts +65 -0
  62. package/src/sse/offline-queue.ts +140 -0
  63. package/src/test-support/app-simulator.ts +243 -0
  64. package/src/test-support/mock-dispatch.ts +181 -0
  65. package/src/test-support/mock-runtime.ts +74 -0
  66. package/src/vendor/runtime-store.ts +99 -0
  67. package/tsconfig.json +17 -0
package/README.md ADDED
@@ -0,0 +1,35 @@
1
+ # Friday Next Channel Plugin
2
+
3
+ `friday-next` is an OpenClaw channel plugin for Apple apps (iOS/macOS) using HTTP + standard SSE.
4
+
5
+ ## Features
6
+
7
+ - Channel id: `friday-next` (alongside legacy `friday` if configured)
8
+ - **Transparent proxy SSE**: forwards OpenClaw `onAgentEvent` as `event: agent`, dispatch `deliver` as `event: deliver`, tool hooks as `event: tool-hook`, channel pushes as `event: outbound`
9
+ - **Single synthetic event**: `connected` (`deviceId`, `serverTime`, `lastSeq`)
10
+ - **Disk-backed SSE replay** per `deviceId` (JSONL + `Last-Event-ID` / `lastEventId`) for offline gaps and restarts
11
+ - File upload/download (`POST /friday-next/files`, `GET /friday-next/files/:id`)
12
+ - Cancel (`POST /friday-next/cancel`) and status with `activeRuns` (`GET /friday-next/status`)
13
+
14
+ ## Endpoints
15
+
16
+ - `GET /friday-next/events?deviceId=...`
17
+ - `POST /friday-next/messages`
18
+ - `POST /friday-next/files`
19
+ - `GET /friday-next/files/:id`
20
+ - `POST /friday-next/cancel`
21
+ - `GET /friday-next/status`
22
+
23
+ See **`API.md`** (English) and **`API.zh-CN.md`** (Chinese) for payloads, event shapes, offline queue paths, and breaking changes.
24
+
25
+ ## Testing
26
+
27
+ - `pnpm test:unit` — Vitest unit tests (excludes `*.e2e.test.ts`)
28
+ - `pnpm test:e2e` — in-process app simulator (`vitest.e2e.config.ts`)
29
+ - `pnpm test` — `test:unit` then `test:e2e`
30
+ - `pnpm test:smoke` — optional live gateway smoke (gateway running; token from env or config)
31
+
32
+ ## Migration
33
+
34
+ - There is **no** `GET/DELETE /friday-next/history`; clients must reconstruct state from SSE.
35
+ - Legacy SSE names (`final`, `run-start`, `run-complete`, `run-error`, `reasoning`, `attachment`, `tts`, `block`, …) are **not** emitted; use `agent` + `deliver` + `tool-hook` + `outbound`.
package/index.ts ADDED
@@ -0,0 +1,191 @@
1
+ import type { ChannelPlugin } from "openclaw/plugin-sdk/core";
2
+ import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core";
3
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
4
+ import type { PluginHookBeforeToolCallEvent, PluginHookAfterToolCallEvent, PluginHookToolContext } from "openclaw/plugin-sdk/plugins/types";
5
+ import { fridayNextChannelPlugin } from "./src/channel.js";
6
+ import { setFridayNextRuntime } from "./src/runtime.js";
7
+ import { resolveFridayNextConfig } from "./src/config.js";
8
+ import { getHostOpenClawConfigSnapshot } from "./src/host-config.js";
9
+ import { registerFridayNextHttpRoutes } from "./src/http/server.js";
10
+ import { getFridayNextRuntime } from "./src/runtime.js";
11
+ import { sseEmitter } from "./src/sse/emitter.js";
12
+ import {
13
+ forwardAgentEventRaw,
14
+ getLastRegisteredFridayDeviceId,
15
+ resolveFridayDeviceIdForSessionKey,
16
+ } from "./src/friday-session.js";
17
+ import { setFridayAgentForwardRuntime } from "./src/agent-forward-runtime.js";
18
+ import { getOpenClawAgentRunContext } from "./src/agent-run-context-bridge.js";
19
+
20
+ export { fridayNextChannelPlugin } from "./src/channel.js";
21
+ export { setFridayNextRuntime } from "./src/runtime.js";
22
+
23
+ /** `api.on` returns void — register tool hooks at most once per process. */
24
+ let fridayNextToolHooksRegistered = false;
25
+ let disposeAgentEventListener: (() => void) | null = null;
26
+ /** Avoid duplicate `registerHttpRoute` when gateway re-invokes `registerFull`. */
27
+ let fridayNextPluginHttpRegistered = false;
28
+
29
+ function deviceIdFromToolContext(ctx: PluginHookToolContext): string | null {
30
+ if (ctx.runId) {
31
+ const d = sseEmitter.getDeviceIdByRunId(ctx.runId);
32
+ if (d) return d;
33
+ }
34
+ const sk =
35
+ typeof ctx.sessionKey === "string" && ctx.sessionKey.trim()
36
+ ? ctx.sessionKey.trim()
37
+ : (ctx.runId ? getOpenClawAgentRunContext(ctx.runId)?.sessionKey?.trim() : undefined) ?? "";
38
+ if (sk) {
39
+ const d = resolveFridayDeviceIdForSessionKey(sk);
40
+ if (d) return d;
41
+ }
42
+ const sole = sseEmitter.getSoleConnectedDeviceId();
43
+ if (sole) return sole;
44
+ const last = getLastRegisteredFridayDeviceId();
45
+ if (last) return last;
46
+ return null;
47
+ }
48
+
49
+ function isFridaySessionKey(sk: string): boolean {
50
+ return /^friday-next-/i.test(sk) || /^agent:main:friday-next-/i.test(sk);
51
+ }
52
+
53
+ function shouldForwardToolEventToFriday(ctx: PluginHookToolContext): boolean {
54
+ if (ctx.runId) {
55
+ if (sseEmitter.getDeviceIdByRunId(ctx.runId)) return true;
56
+ const runSk = getOpenClawAgentRunContext(ctx.runId)?.sessionKey?.trim() ?? "";
57
+ if (runSk) {
58
+ if (resolveFridayDeviceIdForSessionKey(runSk)) return true;
59
+ if (isFridaySessionKey(runSk)) return true;
60
+ }
61
+ }
62
+
63
+ const sk = typeof ctx.sessionKey === "string" ? ctx.sessionKey.trim() : "";
64
+ if (sk) {
65
+ if (resolveFridayDeviceIdForSessionKey(sk)) return true;
66
+ if (isFridaySessionKey(sk)) return true;
67
+ }
68
+
69
+ return false;
70
+ }
71
+
72
+ export default defineChannelPluginEntry({
73
+ id: "friday-next",
74
+ name: "Friday Next",
75
+ description: "Friday Next Apple 应用通道",
76
+ plugin: fridayNextChannelPlugin as ChannelPlugin,
77
+ setRuntime: setFridayNextRuntime,
78
+ registerFull: (api: OpenClawPluginApi) => {
79
+ setFridayAgentForwardRuntime(api);
80
+ if (!fridayNextPluginHttpRegistered) {
81
+ fridayNextPluginHttpRegistered = true;
82
+ registerFridayNextHttpRoutes(api);
83
+ } else {
84
+ const cfg = resolveFridayNextConfig(getHostOpenClawConfigSnapshot(getFridayNextRuntime().config));
85
+ sseEmitter.setBacklogLimit(cfg.sseBacklogPerDevice);
86
+ }
87
+
88
+ disposeAgentEventListener?.();
89
+ disposeAgentEventListener = api.runtime.events.onAgentEvent((evt: any) => {
90
+ forwardAgentEventRaw({
91
+ runId: evt.runId,
92
+ seq: evt.seq,
93
+ ts: evt.ts,
94
+ stream: evt.stream as string,
95
+ data: evt.data as Record<string, unknown>,
96
+ sessionKey: evt.sessionKey,
97
+ });
98
+ });
99
+
100
+ if (fridayNextToolHooksRegistered) {
101
+ return;
102
+ }
103
+ fridayNextToolHooksRegistered = true;
104
+
105
+ api.on("subagent_delivery_target", (event: any) => {
106
+ if (!event.expectsCompletionMessage) return;
107
+ const ch = event.requesterOrigin?.channel?.trim().toLowerCase();
108
+ if (ch !== "friday-next") return;
109
+ const sk = event.requesterSessionKey?.trim();
110
+ if (!sk) return;
111
+ const raw = resolveFridayDeviceIdForSessionKey(sk);
112
+ if (!raw) return;
113
+ const to = raw.toUpperCase();
114
+ return {
115
+ origin: {
116
+ channel: "friday-next",
117
+ accountId: event.requesterOrigin?.accountId?.trim() || "default",
118
+ to,
119
+ },
120
+ };
121
+ });
122
+
123
+ api.on("before_tool_call", (event: PluginHookBeforeToolCallEvent, ctx: PluginHookToolContext) => {
124
+ if (!shouldForwardToolEventToFriday(ctx)) return;
125
+ const deviceId = deviceIdFromToolContext(ctx);
126
+ const runId = ctx.runId ?? "(unknown)";
127
+
128
+ const logLine = (detail: string) => {
129
+ const ts = new Date().toISOString();
130
+ console.error(
131
+ `[Friday-HOOK] [${ts}] [TOOL_CALL] toolName=${event.toolName} runId=${runId} deviceId=${deviceId ?? "(unknown)"} detail=${detail}`,
132
+ );
133
+ };
134
+
135
+ if (!deviceId) {
136
+ logLine("SKIP_no_deviceId");
137
+ return;
138
+ }
139
+
140
+ logLine("START");
141
+ sseEmitter.broadcastToolEvent(deviceId.toUpperCase(), runId, {
142
+ type: "tool-hook",
143
+ data: {
144
+ when: "before",
145
+ runId,
146
+ deviceId: deviceId.toUpperCase(),
147
+ sessionKey: ctx.sessionKey,
148
+ toolName: event.toolName,
149
+ params: event.params,
150
+ ts: Date.now(),
151
+ },
152
+ });
153
+ });
154
+
155
+ api.on("after_tool_call", (event: PluginHookAfterToolCallEvent, ctx: PluginHookToolContext) => {
156
+ if (!shouldForwardToolEventToFriday(ctx)) return;
157
+ const deviceId = deviceIdFromToolContext(ctx);
158
+ const runId = ctx.runId ?? "(unknown)";
159
+
160
+ const logLine = (detail: string) => {
161
+ const ts = new Date().toISOString();
162
+ console.error(
163
+ `[Friday-HOOK] [${ts}] [TOOL_DONE] toolName=${event.toolName} runId=${runId} deviceId=${deviceId ?? "(unknown)"} detail=${detail}`,
164
+ );
165
+ };
166
+
167
+ if (!deviceId) {
168
+ logLine("SKIP_no_deviceId");
169
+ return;
170
+ }
171
+
172
+ logLine("END");
173
+ const normalizedDeviceId = deviceId.toUpperCase();
174
+ sseEmitter.broadcastToolEvent(normalizedDeviceId, runId, {
175
+ type: "tool-hook",
176
+ data: {
177
+ when: "after",
178
+ runId,
179
+ deviceId: normalizedDeviceId,
180
+ sessionKey: ctx.sessionKey,
181
+ toolName: event.toolName,
182
+ toolCallId: event.toolCallId,
183
+ error: event.error ?? null,
184
+ result: event.result,
185
+ durationMs: event.durationMs ?? null,
186
+ ts: Date.now(),
187
+ },
188
+ });
189
+ });
190
+ },
191
+ });
package/install.mjs ADDED
@@ -0,0 +1,158 @@
1
+ #!/usr/bin/env node
2
+ import { execSync } from "node:child_process";
3
+ import { cpSync, existsSync, readFileSync, writeFileSync } from "node:fs";
4
+ import { homedir } from "node:os";
5
+ import { dirname, join, relative, resolve, sep } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+
8
+ const __filename = fileURLToPath(import.meta.url);
9
+ const __dirname = dirname(__filename);
10
+
11
+ const PLUGIN_DIR = process.argv[2] || join(homedir(), ".openclaw", "extensions", "friday-channel-next");
12
+ const OPENCLAW_CONFIG = join(homedir(), ".openclaw", "openclaw.json");
13
+ const REPO_URL = process.env.FRIDAY_NEXT_REPO || "https://github.com/SyengUp/openclaw-fridaynext-channel.git";
14
+
15
+ const G = (s) => `\x1b[32m${s}\x1b[0m`;
16
+ const Y = (s) => `\x1b[33m${s}\x1b[0m`;
17
+ const R = (s) => `\x1b[31m${s}\x1b[0m`;
18
+
19
+ function log(msg) {
20
+ console.log(`${G("[friday-next]")} ${msg}`);
21
+ }
22
+ function warn(msg) {
23
+ console.log(`${Y("[friday-next]")} ${msg}`);
24
+ }
25
+ function err(msg) {
26
+ console.error(`${R("[friday-next]")} ${msg}`);
27
+ }
28
+
29
+ function has(cmd) {
30
+ try {
31
+ execSync(`${cmd} --version`, { stdio: "ignore" });
32
+ return true;
33
+ } catch {
34
+ return false;
35
+ }
36
+ }
37
+
38
+ // Running from an npm/npx package when we have the full source (index.ts + package.json)
39
+ // and are NOT already inside the target plugin dir.
40
+ function isRunningFromNpmPackage() {
41
+ return (
42
+ resolve(__dirname) !== resolve(PLUGIN_DIR) &&
43
+ existsSync(join(__dirname, "package.json")) &&
44
+ existsSync(join(__dirname, "index.ts"))
45
+ );
46
+ }
47
+
48
+ // --------------- prerequisites ---------------
49
+
50
+ const required = ["pnpm", "node", "openclaw"];
51
+ const missing = required.filter((c) => !has(c));
52
+ if (missing.length) {
53
+ missing.forEach((c) => err(`${c} is required but not found. Install it first.`));
54
+ process.exit(1);
55
+ }
56
+
57
+ if (!existsSync(OPENCLAW_CONFIG)) {
58
+ err(`OpenClaw config not found at ${OPENCLAW_CONFIG}`);
59
+ err("Make sure OpenClaw is installed and has been run at least once.");
60
+ process.exit(1);
61
+ }
62
+
63
+ // --------------- acquire source ---------------
64
+
65
+ if (existsSync(PLUGIN_DIR)) {
66
+ log(`Plugin directory found: ${PLUGIN_DIR}`);
67
+ } else if (isRunningFromNpmPackage()) {
68
+ log(`Copying plugin from npm package to ${PLUGIN_DIR} ...`);
69
+ cpSync(__dirname, PLUGIN_DIR, {
70
+ recursive: true,
71
+ filter: (src) => {
72
+ const rel = relative(__dirname, src);
73
+ if (rel === "") return true; // root dir
74
+ const top = rel.split(sep)[0];
75
+ return ![".git", "node_modules", "dist", "attachments", ".claude"].includes(top);
76
+ },
77
+ });
78
+ } else {
79
+ if (!has("git")) {
80
+ err("git is required for installation from GitHub. Install git first or use npx @openclaw/friday-channel-next.");
81
+ process.exit(1);
82
+ }
83
+ log(`Cloning plugin to ${PLUGIN_DIR} ...`);
84
+ execSync(`git clone "${REPO_URL}" "${PLUGIN_DIR}"`, { stdio: "inherit" });
85
+ }
86
+
87
+ process.chdir(PLUGIN_DIR);
88
+
89
+ // --------------- install + build ---------------
90
+
91
+ log("Installing dependencies...");
92
+ try {
93
+ execSync("pnpm install --frozen-lockfile", { stdio: "inherit" });
94
+ } catch {
95
+ execSync("pnpm install", { stdio: "inherit" });
96
+ }
97
+
98
+ log("Building TypeScript...");
99
+ execSync("pnpm build", { stdio: "inherit" });
100
+
101
+ // --------------- configure OpenClaw ---------------
102
+
103
+ log("Configuring OpenClaw...");
104
+
105
+ const config = JSON.parse(readFileSync(OPENCLAW_CONFIG, "utf8"));
106
+
107
+ if (!config.plugins) config.plugins = {};
108
+ if (!Array.isArray(config.plugins.allow)) config.plugins.allow = [];
109
+ if (!config.plugins.allow.includes("friday-next")) {
110
+ config.plugins.allow.push("friday-next");
111
+ console.log(" + Added friday-next to plugins.allow");
112
+ }
113
+
114
+ if (!config.plugins.entries) config.plugins.entries = {};
115
+ if (!config.plugins.entries["friday-next"]) {
116
+ config.plugins.entries["friday-next"] = { enabled: true };
117
+ console.log(" + Added friday-next to plugins.entries (enabled)");
118
+ } else if (!config.plugins.entries["friday-next"].enabled) {
119
+ config.plugins.entries["friday-next"].enabled = true;
120
+ console.log(" + Enabled friday-next in plugins.entries");
121
+ }
122
+
123
+ if (!config.channels) config.channels = {};
124
+ if (!config.channels["friday-next"]) {
125
+ config.channels["friday-next"] = { enabled: true, transport: "http+sse" };
126
+ console.log(" + Added friday-next channel config (auth defaults to gateway token)");
127
+ } else {
128
+ if (!config.channels["friday-next"].enabled) {
129
+ config.channels["friday-next"].enabled = true;
130
+ console.log(" + Enabled friday-next channel");
131
+ }
132
+ if (!config.channels["friday-next"].transport) {
133
+ config.channels["friday-next"].transport = "http+sse";
134
+ console.log(" + Set friday-next transport to http+sse");
135
+ }
136
+ }
137
+
138
+ if (!config.gateway) config.gateway = {};
139
+ if (config.gateway.bind !== "lan") {
140
+ config.gateway.bind = "lan";
141
+ console.log(" + Set gateway.bind to lan");
142
+ }
143
+
144
+ writeFileSync(OPENCLAW_CONFIG, JSON.stringify(config, null, 2) + "\n", "utf8");
145
+ console.log(" Config updated.");
146
+
147
+ // --------------- restart gateway ---------------
148
+
149
+ log("Restarting OpenClaw gateway...");
150
+ execSync("openclaw gateway restart", { stdio: "inherit" });
151
+
152
+ log("--------------------------------------------------");
153
+ log("Installation complete! Friday Next channel is now active.");
154
+ log("");
155
+ log("The channel uses your gateway auth token by default.");
156
+ log("To use a different token, set FRIDAY_NEXT_AUTH_TOKEN env var or");
157
+ log("add authToken to channels.friday-next in openclaw.json.");
158
+ log("--------------------------------------------------");
package/install.sh ADDED
@@ -0,0 +1,118 @@
1
+ #!/usr/bin/env bash
2
+
3
+ # Friday Next plugin installer — one-click setup for the friday-next channel
4
+ # Usage:
5
+ # ./install.sh [plugin-dir]
6
+ # curl -fsSL https://raw.githubusercontent.com/SyengUp/openclaw-fridaynext-channel/main/install.sh | bash
7
+
8
+ set -e
9
+
10
+ PLUGIN_DIR="${1:-$HOME/.openclaw/extensions/friday-channel-next}"
11
+ OPENCLAW_CONFIG="$HOME/.openclaw/openclaw.json"
12
+
13
+ RED='\033[0;31m'
14
+ GREEN='\033[0;32m'
15
+ YELLOW='\033[1;33m'
16
+ NC='\033[0m'
17
+
18
+ log() { printf "%b%s\\n" "${GREEN}[friday-next]${NC} " "$1"; }
19
+ warn() { printf "%b%s\\n" "${YELLOW}[friday-next]${NC} " "$1"; }
20
+ err() { printf "%b%s\\n" "${RED}[friday-next]${NC} " "$1" >&2; }
21
+
22
+ trap 'err "Install failed."' ERR
23
+
24
+ # Check prerequisites
25
+ for cmd in pnpm node git openclaw; do
26
+ if ! command -v "$cmd" &>/dev/null; then
27
+ err "$cmd is required but not found. Install it first."
28
+ exit 1
29
+ fi
30
+ done
31
+
32
+ if [ ! -f "$OPENCLAW_CONFIG" ]; then
33
+ err "OpenClaw config not found at $OPENCLAW_CONFIG"
34
+ err "Make sure OpenClaw is installed and has been run at least once."
35
+ exit 1
36
+ fi
37
+
38
+ # Step 1: Clone (if needed), install deps, and build
39
+
40
+ if [ -d "$PLUGIN_DIR" ]; then
41
+ log "Plugin directory found: $PLUGIN_DIR"
42
+ else
43
+ log "Cloning plugin to $PLUGIN_DIR ..."
44
+ REPO_URL="${FRIDAY_NEXT_REPO:-https://github.com/SyengUp/openclaw-fridaynext-channel.git}"
45
+ git clone "$REPO_URL" "$PLUGIN_DIR"
46
+ fi
47
+
48
+ cd "$PLUGIN_DIR"
49
+
50
+ log "Installing dependencies..."
51
+ pnpm install --frozen-lockfile 2>/dev/null || pnpm install
52
+
53
+ log "Building TypeScript..."
54
+ pnpm build
55
+
56
+ # Step 2: Configure OpenClaw
57
+
58
+ log "Configuring OpenClaw..."
59
+
60
+ node --input-type=module -e '
61
+ import fs from "node:fs";
62
+
63
+ const configPath = process.argv[1];
64
+ const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
65
+
66
+ if (!config.plugins) config.plugins = {};
67
+ if (!Array.isArray(config.plugins.allow)) config.plugins.allow = [];
68
+ if (!config.plugins.allow.includes("friday-next")) {
69
+ config.plugins.allow.push("friday-next");
70
+ console.log(" + Added friday-next to plugins.allow");
71
+ }
72
+
73
+ if (!config.plugins.entries) config.plugins.entries = {};
74
+ if (!config.plugins.entries["friday-next"]) {
75
+ config.plugins.entries["friday-next"] = { enabled: true };
76
+ console.log(" + Added friday-next to plugins.entries (enabled)");
77
+ } else if (!config.plugins.entries["friday-next"].enabled) {
78
+ config.plugins.entries["friday-next"].enabled = true;
79
+ console.log(" + Enabled friday-next in plugins.entries");
80
+ }
81
+
82
+ if (!config.channels) config.channels = {};
83
+ if (!config.channels["friday-next"]) {
84
+ config.channels["friday-next"] = { enabled: true, transport: "http+sse" };
85
+ console.log(" + Added friday-next channel config (auth defaults to gateway token)");
86
+ } else {
87
+ if (!config.channels["friday-next"].enabled) {
88
+ config.channels["friday-next"].enabled = true;
89
+ console.log(" + Enabled friday-next channel");
90
+ }
91
+ if (!config.channels["friday-next"].transport) {
92
+ config.channels["friday-next"].transport = "http+sse";
93
+ console.log(" + Set friday-next transport to http+sse");
94
+ }
95
+ }
96
+
97
+ if (!config.gateway) config.gateway = {};
98
+ if (config.gateway.bind !== "lan") {
99
+ config.gateway.bind = "lan";
100
+ console.log(" + Set gateway.bind to lan");
101
+ }
102
+
103
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf8");
104
+ console.log(" Config updated.");
105
+ ' "$OPENCLAW_CONFIG"
106
+
107
+ # Step 3: Restart gateway
108
+
109
+ log "Restarting OpenClaw gateway..."
110
+ openclaw gateway restart
111
+
112
+ log "--------------------------------------------------"
113
+ log "Installation complete! Friday Next channel is now active."
114
+ log ""
115
+ log "The channel uses your gateway auth token by default."
116
+ log "To use a different token, set FRIDAY_NEXT_AUTH_TOKEN env var or"
117
+ log "add authToken to channels.friday-next in openclaw.json."
118
+ log "--------------------------------------------------"
@@ -0,0 +1,53 @@
1
+ {
2
+ "id": "friday-next",
3
+ "channels": ["friday-next"],
4
+ "configSchema": {
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "properties": {}
8
+ },
9
+ "channelConfigs": {
10
+ "friday-next": {
11
+ "label": "Friday Next",
12
+ "description": "HTTP+SSE channel for the Friday iOS client.",
13
+ "schema": {
14
+ "type": "object",
15
+ "additionalProperties": false,
16
+ "properties": {
17
+ "enabled": { "type": "boolean" },
18
+ "transport": { "type": "string" },
19
+ "pathPrefix": { "type": "string" },
20
+ "historyLimit": { "type": "integer", "minimum": 1, "maximum": 200 },
21
+ "historyDir": { "type": "string" },
22
+ "logLevel": { "type": "string", "enum": ["debug", "info", "warn", "error"] },
23
+ "authToken": { "type": "string" },
24
+ "cors": {
25
+ "type": "object",
26
+ "additionalProperties": false,
27
+ "properties": {
28
+ "enabled": { "type": "boolean" },
29
+ "allowOrigin": { "type": "string" }
30
+ }
31
+ },
32
+ "sse": {
33
+ "type": "object",
34
+ "additionalProperties": false,
35
+ "properties": {
36
+ "keepaliveSec": { "type": "integer", "minimum": 5, "maximum": 120 },
37
+ "backlogPerDevice": { "type": "integer", "minimum": 0, "maximum": 1000 }
38
+ }
39
+ }
40
+ }
41
+ },
42
+ "uiHints": {
43
+ "authToken": {
44
+ "label": "Bearer auth token",
45
+ "sensitive": true
46
+ }
47
+ }
48
+ }
49
+ },
50
+ "channelEnvVars": {
51
+ "friday-next": ["FRIDAY_NEXT_AUTH_TOKEN"]
52
+ }
53
+ }
package/package.json ADDED
@@ -0,0 +1,65 @@
1
+ {
2
+ "name": "@syengup/friday-channel-next",
3
+ "version": "0.0.1",
4
+ "description": "OpenClaw Friday Next Apple channel plugin",
5
+ "type": "module",
6
+ "files": [
7
+ "index.ts",
8
+ "src/",
9
+ "install.mjs",
10
+ "install.sh",
11
+ "tsconfig.json",
12
+ "pnpm-lock.yaml",
13
+ "openclaw.plugin.json"
14
+ ],
15
+ "scripts": {
16
+ "build": "tsc -p tsconfig.json",
17
+ "test": "pnpm test:unit && pnpm test:e2e",
18
+ "test:unit": "vitest run",
19
+ "test:e2e": "vitest run --config vitest.e2e.config.ts",
20
+ "test:smoke": "node scripts/e2e-smoke.mjs",
21
+ "test:msg-live": "node scripts/message-roundtrip-live.mjs"
22
+ },
23
+ "bin": {
24
+ "install-friday-next": "./install.mjs"
25
+ },
26
+ "main": "./dist/index.js",
27
+ "types": "./dist/index.d.ts",
28
+ "exports": {
29
+ ".": {
30
+ "import": {
31
+ "types": "./dist/index.d.ts",
32
+ "default": "./dist/index.js"
33
+ }
34
+ }
35
+ },
36
+ "openclaw": {
37
+ "extensions": [
38
+ "./index.ts"
39
+ ],
40
+ "channel": {
41
+ "id": "friday-next",
42
+ "label": "Friday Next",
43
+ "selectionLabel": "Friday Next (Apple App)",
44
+ "detailLabel": "Friday Next Apple App",
45
+ "docsPath": "/channels/friday-next",
46
+ "docsLabel": "friday-next",
47
+ "blurb": "Apple app channel with HTTP + SSE v2 streaming.",
48
+ "systemImage": "iphone.radiowaves.left.and.right",
49
+ "markdownCapable": true
50
+ },
51
+ "bundle": {
52
+ "stageRuntimeDependencies": true
53
+ }
54
+ },
55
+ "devDependencies": {
56
+ "@types/node": "^25.6.0",
57
+ "chalk": "^5.6.2",
58
+ "jiti": "^2.6.1",
59
+ "json5": "^2.2.3",
60
+ "tslog": "^4.10.2",
61
+ "typescript": "^6.0.3",
62
+ "vitest": "^4.1.5",
63
+ "zod": "^4.3.6"
64
+ }
65
+ }
@@ -0,0 +1,10 @@
1
+ export async function abortRun(runId: string): Promise<void> {
2
+ if (process.env.VITEST !== "true") {
3
+ try {
4
+ const { abortAgentHarnessRun } = await import("openclaw/plugin-sdk/agent-harness");
5
+ abortAgentHarnessRun(runId);
6
+ } catch {
7
+ // optional at runtime
8
+ }
9
+ }
10
+ }
@@ -0,0 +1,26 @@
1
+ /** Tracks agent runs that have emitted lifecycle `phase: start` without matching `end`/`error`. */
2
+
3
+ const active = new Set<string>();
4
+
5
+ export function observeAgentEventForActiveRuns(evt: {
6
+ stream: string;
7
+ runId: string;
8
+ data: Record<string, unknown>;
9
+ }): void {
10
+ if (evt.stream !== "lifecycle") return;
11
+ const phase = evt.data.phase;
12
+ if (phase === "start") active.add(evt.runId);
13
+ if (phase === "end" || phase === "error") active.delete(evt.runId);
14
+ }
15
+
16
+ export function getActiveRunIds(): string[] {
17
+ return [...active];
18
+ }
19
+
20
+ export function getActiveRunCount(): number {
21
+ return active.size;
22
+ }
23
+
24
+ export function resetActiveRunsForTest(): void {
25
+ active.clear();
26
+ }
@@ -0,0 +1,18 @@
1
+ type DispatchFn = (args: unknown) => Promise<unknown> | unknown;
2
+
3
+ let overrideDispatch: DispatchFn | null = null;
4
+
5
+ export function runFridayDispatch(args: Parameters<DispatchFn>[0]): ReturnType<DispatchFn> {
6
+ if (overrideDispatch) return overrideDispatch(args);
7
+ return import("openclaw/plugin-sdk/reply-dispatch-runtime").then((m) =>
8
+ m.dispatchReplyWithDispatcher(args as never),
9
+ );
10
+ }
11
+
12
+ export function __setMockFridayDispatchForTests(fn: DispatchFn): void {
13
+ overrideDispatch = fn;
14
+ }
15
+
16
+ export function __resetMockFridayDispatchForTests(): void {
17
+ overrideDispatch = null;
18
+ }