@syengup/friday-channel-next 0.1.25 → 0.1.27

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 (35) hide show
  1. package/dist/index.js +2 -0
  2. package/dist/src/http/handlers/health.d.ts +1 -0
  3. package/dist/src/http/handlers/health.js +2 -0
  4. package/dist/src/http/handlers/history-messages.js +50 -9
  5. package/dist/src/http/handlers/messages.d.ts +5 -0
  6. package/dist/src/http/handlers/messages.js +19 -8
  7. package/dist/src/http/handlers/plugin-info.d.ts +11 -0
  8. package/dist/src/http/handlers/plugin-info.js +32 -0
  9. package/dist/src/http/handlers/plugin-upgrade.d.ts +11 -0
  10. package/dist/src/http/handlers/plugin-upgrade.js +94 -0
  11. package/dist/src/http/handlers/sse.js +2 -0
  12. package/dist/src/http/handlers/status.js +2 -0
  13. package/dist/src/http/server.js +10 -0
  14. package/dist/src/plugin-install-info.d.ts +15 -0
  15. package/dist/src/plugin-install-info.js +87 -0
  16. package/dist/src/upgrade-runtime.d.ts +39 -0
  17. package/dist/src/upgrade-runtime.js +27 -0
  18. package/dist/src/version.d.ts +5 -0
  19. package/dist/src/version.js +37 -0
  20. package/index.ts +2 -0
  21. package/package.json +1 -1
  22. package/src/http/handlers/health.ts +3 -0
  23. package/src/http/handlers/history-messages.test.ts +33 -0
  24. package/src/http/handlers/history-messages.ts +46 -8
  25. package/src/http/handlers/messages.test.ts +75 -1
  26. package/src/http/handlers/messages.ts +19 -7
  27. package/src/http/handlers/plugin-info.ts +51 -0
  28. package/src/http/handlers/plugin-upgrade.ts +112 -0
  29. package/src/http/handlers/sse.ts +2 -0
  30. package/src/http/handlers/status.ts +2 -0
  31. package/src/http/server.ts +12 -0
  32. package/src/plugin-install-info.test.ts +28 -0
  33. package/src/plugin-install-info.ts +95 -0
  34. package/src/upgrade-runtime.ts +69 -0
  35. package/src/version.ts +41 -0
package/dist/index.js CHANGED
@@ -8,6 +8,7 @@ import { getFridayNextRuntime } from "./src/runtime.js";
8
8
  import { sseEmitter } from "./src/sse/emitter.js";
9
9
  import { forwardAgentEventRaw, getLastRegisteredFridayDeviceId, resolveFridayDeviceIdForSessionKey, } from "./src/friday-session.js";
10
10
  import { setFridayAgentForwardRuntime } from "./src/agent-forward-runtime.js";
11
+ import { setUpgradeRuntime } from "./src/upgrade-runtime.js";
11
12
  import { getOpenClawAgentRunContext } from "./src/agent-run-context-bridge.js";
12
13
  import { accumulateRunUsage } from "./src/agent/run-usage-accumulator.js";
13
14
  import { createFridayNextLogger } from "./src/logging.js";
@@ -78,6 +79,7 @@ export default defineChannelPluginEntry({
78
79
  setRuntime: setFridayNextRuntime,
79
80
  registerFull: (api) => {
80
81
  setFridayAgentForwardRuntime(api);
82
+ setUpgradeRuntime(api);
81
83
  const sameApi = lastApiRoutesRegistered?.deref() === api;
82
84
  if (!sameApi) {
83
85
  lastApiRoutesRegistered = new WeakRef(api);
@@ -15,6 +15,7 @@ export interface HealthCheckResult {
15
15
  timestamp: number;
16
16
  deviceId: string;
17
17
  nodeDeviceId: string;
18
+ pluginVersion: string;
18
19
  nodePairing?: HealthComponentStatus;
19
20
  repairActions?: RepairAction[];
20
21
  }
@@ -1,6 +1,7 @@
1
1
  import { extractBearerToken } from "../middleware/auth.js";
2
2
  import { loadNodePairingModule } from "../../agent/node-pairing-bridge.js";
3
3
  import { createFridayNextLogger } from "../../logging.js";
4
+ import { PLUGIN_VERSION } from "../../version.js";
4
5
  const REQUIRED_NODE_CAPS = ["location", "canvas"];
5
6
  const REQUIRED_NODE_COMMANDS = [
6
7
  "location.get",
@@ -36,6 +37,7 @@ export async function handleHealth(req, res) {
36
37
  timestamp: Date.now(),
37
38
  deviceId,
38
39
  nodeDeviceId,
40
+ pluginVersion: PLUGIN_VERSION,
39
41
  };
40
42
  const log = createFridayNextLogger("health");
41
43
  if (nodeDeviceId) {
@@ -9,6 +9,7 @@
9
9
  * `runtime.subagent.getSessionMessages`), which already resolves the active
10
10
  * branch and compaction, then normalizes each raw message into a stable DTO.
11
11
  */
12
+ import { fileURLToPath } from "node:url";
12
13
  import { getFridayNextRuntime } from "../../runtime.js";
13
14
  import { extractBearerToken } from "../middleware/auth.js";
14
15
  import { normalizeHistoryMessages } from "../../history/normalize-message.js";
@@ -17,6 +18,28 @@ import { resolveMediaAttachment } from "./files.js";
17
18
  import { readSessionUsageSnapshotFromStore } from "../../session-usage-store.js";
18
19
  const DEFAULT_LIMIT = 200;
19
20
  const MAX_LIMIT = 1000;
21
+ /**
22
+ * For an `images[].url` produced from a `[media attached: …]` marker: returns the
23
+ * server-local filesystem path to resolve (`file://…` or a bare absolute path), or
24
+ * null to leave the image untouched. Already-served `/friday-next/files/…` URLs and
25
+ * remote `http(s)://` / `data:` URLs are NOT local paths — never feed them to
26
+ * `resolveMediaAttachment` (it would mis-treat them as paths and break valid URLs).
27
+ */
28
+ function serverLocalPathForImageUrl(url) {
29
+ if (url.startsWith("file://")) {
30
+ try {
31
+ return fileURLToPath(url);
32
+ }
33
+ catch {
34
+ return url.slice("file://".length);
35
+ }
36
+ }
37
+ if (url.startsWith("/friday-next/files/"))
38
+ return null;
39
+ if (url.startsWith("/"))
40
+ return url;
41
+ return null;
42
+ }
20
43
  function resolveSubagentApi() {
21
44
  try {
22
45
  const runtime = getFridayNextRuntime();
@@ -75,16 +98,34 @@ export async function handleHistoryMessages(req, res) {
75
98
  // (copies the file into the plugin's attachments/ dir — the same mechanism the
76
99
  // live deliver path uses), then drop the raw paths from the wire.
77
100
  for (const message of messages) {
78
- if (!message.mediaPaths?.length)
79
- continue;
80
- const resolved = message.mediaPaths
81
- .map((p) => resolveMediaAttachment(p))
82
- .filter((r) => Boolean(r))
83
- .map((r) => ({ url: r.url, filename: r.fileName }));
84
- if (resolved.length) {
85
- message.images = [...(message.images ?? []), ...resolved];
101
+ if (message.mediaPaths?.length) {
102
+ const resolved = message.mediaPaths
103
+ .map((p) => resolveMediaAttachment(p))
104
+ .filter((r) => Boolean(r))
105
+ .map((r) => ({ url: r.url, filename: r.fileName }));
106
+ if (resolved.length) {
107
+ message.images = [...(message.images ?? []), ...resolved];
108
+ }
109
+ delete message.mediaPaths;
110
+ }
111
+ // User attachments arrive as `[media attached: file://<server-path>]` markers,
112
+ // which normalize-message extracts into images[].url as a RAW server-local path.
113
+ // Unlike MEDIA: paths (resolved above), these were never copied into the file
114
+ // store, so the app would try to load a path that only exists on the gateway host
115
+ // and the attachment bubble is lost on history sync. Resolve them the same way.
116
+ if (message.images?.length) {
117
+ message.images = message.images.map((img) => {
118
+ if (!img.url || img.data)
119
+ return img;
120
+ const local = serverLocalPathForImageUrl(img.url);
121
+ if (!local)
122
+ return img;
123
+ const resolved = resolveMediaAttachment(local);
124
+ if (!resolved)
125
+ return img;
126
+ return { ...img, url: resolved.url, filename: img.filename ?? resolved.fileName };
127
+ });
86
128
  }
87
- delete message.mediaPaths;
88
129
  }
89
130
  const sessionId = resolveSessionId(sessionKey);
90
131
  // Cumulative session-usage snapshot (model + context window/used) read from the
@@ -47,4 +47,9 @@ export interface FridayMessagePayload {
47
47
  reasoningLevel?: string;
48
48
  thinkingLevel?: string;
49
49
  }
50
+ /**
51
+ * 把可选文本与媒体引用拼成给 agent 的最终 body。纯附件(文本为空)场景下
52
+ * 不能带前导空行,否则 agent 收到的是 `\n\n[media…]`——故拆成可测纯函数。
53
+ */
54
+ export declare function composeBodyWithMediaRefs(text: string, mediaRefs: string[]): string;
50
55
  export declare function handleMessages(req: IncomingMessage, res: ServerResponse): Promise<boolean>;
@@ -262,6 +262,16 @@ async function resolveRunMetadataFromRuntimeSession(runtime, sessionKey) {
262
262
  }
263
263
  return null;
264
264
  }
265
+ /**
266
+ * 把可选文本与媒体引用拼成给 agent 的最终 body。纯附件(文本为空)场景下
267
+ * 不能带前导空行,否则 agent 收到的是 `\n\n[media…]`——故拆成可测纯函数。
268
+ */
269
+ export function composeBodyWithMediaRefs(text, mediaRefs) {
270
+ const trimmed = text.trim();
271
+ if (mediaRefs.length === 0)
272
+ return trimmed;
273
+ return trimmed ? `${trimmed}\n\n${mediaRefs.join("\n")}` : mediaRefs.join("\n");
274
+ }
265
275
  async function buildBodyForAgentWithAttachments(text, attachmentIds) {
266
276
  if (attachmentIds.length === 0)
267
277
  return text.trim();
@@ -275,9 +285,7 @@ async function buildBodyForAgentWithAttachments(text, attachmentIds) {
275
285
  mediaRefs.push(`[media attached: file://${saved.path}]`);
276
286
  }
277
287
  }
278
- if (mediaRefs.length === 0)
279
- return text.trim();
280
- return `${text.trim()}\n\n${mediaRefs.join("\n")}`;
288
+ return composeBodyWithMediaRefs(text, mediaRefs);
281
289
  }
282
290
  export async function handleMessages(req, res) {
283
291
  if (req.method !== "POST") {
@@ -320,14 +328,17 @@ export async function handleMessages(req, res) {
320
328
  res.end(JSON.stringify({ error: "Missing required field: deviceId" }));
321
329
  return true;
322
330
  }
323
- if (!text || !text.trim()) {
324
- log("BAD_REQUEST", normalizedDeviceId, undefined, "missing text", "warn");
331
+ // 允许"只发附件、不发文本":text attachments 不能同时为空,但任一非空即放行。
332
+ const hasText = Boolean(text && text.trim());
333
+ const hasAttachments = Array.isArray(attachments) && attachments.length > 0;
334
+ if (!hasText && !hasAttachments) {
335
+ log("BAD_REQUEST", normalizedDeviceId, undefined, "missing text and attachments", "warn");
325
336
  res.statusCode = 400;
326
337
  res.setHeader("Content-Type", "application/json");
327
- res.end(JSON.stringify({ error: "Missing required field: text" }));
338
+ res.end(JSON.stringify({ error: "Missing required field: text or attachments" }));
328
339
  return true;
329
340
  }
330
- const trimmedText = text.trim();
341
+ const trimmedText = (text ?? "").trim();
331
342
  touchFridayInbound();
332
343
  const isSlashCommand = trimmedText.startsWith("/");
333
344
  const runId = crypto.randomUUID();
@@ -363,7 +374,7 @@ export async function handleMessages(req, res) {
363
374
  registerFridaySessionDeviceMapping(appSessionKey, normalizedDeviceId);
364
375
  sseEmitter.trackDeviceForRun(normalizedDeviceId, runId);
365
376
  registerRunRoute({ runId, deviceId: normalizedDeviceId, sessionKey: baseSessionKey });
366
- const bodyForAgent = await buildBodyForAgentWithAttachments(text, attachments);
377
+ const bodyForAgent = await buildBodyForAgentWithAttachments(trimmedText, attachments);
367
378
  const msgContext = {
368
379
  Body: trimmedText,
369
380
  BodyForAgent: bodyForAgent,
@@ -0,0 +1,11 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ export interface PluginInfoResult {
3
+ currentVersion: string;
4
+ latestVersion: string | null;
5
+ installSource: string;
6
+ /** True when the install is npm-managed (the only auto-upgradable source). */
7
+ canAutoUpgrade: boolean;
8
+ /** True when a newer version is published AND the install can be auto-upgraded. */
9
+ upgradable: boolean;
10
+ }
11
+ export declare function handlePluginInfo(req: IncomingMessage, res: ServerResponse): Promise<boolean>;
@@ -0,0 +1,32 @@
1
+ import { extractBearerToken } from "../middleware/auth.js";
2
+ import { PLUGIN_VERSION } from "../../version.js";
3
+ import { fetchLatestVersion, getInstallSource, semverGreater, } from "../../plugin-install-info.js";
4
+ export async function handlePluginInfo(req, res) {
5
+ if (req.method !== "GET") {
6
+ res.statusCode = 405;
7
+ res.setHeader("Content-Type", "application/json");
8
+ res.end(JSON.stringify({ error: "Method Not Allowed" }));
9
+ return true;
10
+ }
11
+ if (!extractBearerToken(req)) {
12
+ res.statusCode = 401;
13
+ res.setHeader("Content-Type", "application/json");
14
+ res.end(JSON.stringify({ error: "Unauthorized: bearer token mismatch" }));
15
+ return true;
16
+ }
17
+ const installSource = getInstallSource();
18
+ const canAutoUpgrade = installSource === "npm";
19
+ const latestVersion = await fetchLatestVersion(Date.now());
20
+ const upgradable = canAutoUpgrade && semverGreater(latestVersion, PLUGIN_VERSION);
21
+ const result = {
22
+ currentVersion: PLUGIN_VERSION,
23
+ latestVersion,
24
+ installSource,
25
+ canAutoUpgrade,
26
+ upgradable,
27
+ };
28
+ res.statusCode = 200;
29
+ res.setHeader("Content-Type", "application/json");
30
+ res.end(JSON.stringify(result));
31
+ return true;
32
+ }
@@ -0,0 +1,11 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ /**
3
+ * POST /friday-next/plugin/upgrade
4
+ *
5
+ * Runs `openclaw plugins install @syengup/friday-channel-next@latest --force`
6
+ * (registry-aware, updates the install record), responds 202, then triggers a
7
+ * safe gateway restart so the new version loads. Only npm-installed plugins are
8
+ * eligible — dev (load.paths / source==="path") installs return 409 to protect
9
+ * the dev environment from duplicate npm installs.
10
+ */
11
+ export declare function handlePluginUpgrade(req: IncomingMessage, res: ServerResponse): Promise<boolean>;
@@ -0,0 +1,94 @@
1
+ import { extractBearerToken } from "../middleware/auth.js";
2
+ import { createFridayNextLogger } from "../../logging.js";
3
+ import { PLUGIN_PACKAGE_NAME, PLUGIN_VERSION } from "../../version.js";
4
+ import { getInstallSource } from "../../plugin-install-info.js";
5
+ import { getUpgradeRuntime } from "../../upgrade-runtime.js";
6
+ const UPGRADE_TIMEOUT_MS = 120_000;
7
+ /** Give the 202 response time to flush before the restart kills the process. */
8
+ const RESTART_DELAY_MS = 500;
9
+ /**
10
+ * POST /friday-next/plugin/upgrade
11
+ *
12
+ * Runs `openclaw plugins install @syengup/friday-channel-next@latest --force`
13
+ * (registry-aware, updates the install record), responds 202, then triggers a
14
+ * safe gateway restart so the new version loads. Only npm-installed plugins are
15
+ * eligible — dev (load.paths / source==="path") installs return 409 to protect
16
+ * the dev environment from duplicate npm installs.
17
+ */
18
+ export async function handlePluginUpgrade(req, res) {
19
+ if (req.method !== "POST") {
20
+ res.statusCode = 405;
21
+ res.setHeader("Content-Type", "application/json");
22
+ res.end(JSON.stringify({ error: "Method Not Allowed" }));
23
+ return true;
24
+ }
25
+ if (!extractBearerToken(req)) {
26
+ res.statusCode = 401;
27
+ res.setHeader("Content-Type", "application/json");
28
+ res.end(JSON.stringify({ error: "Unauthorized: bearer token mismatch" }));
29
+ return true;
30
+ }
31
+ const log = createFridayNextLogger("upgrade");
32
+ const installSource = getInstallSource();
33
+ if (installSource !== "npm") {
34
+ res.statusCode = 409;
35
+ res.setHeader("Content-Type", "application/json");
36
+ res.end(JSON.stringify({
37
+ error: "auto-upgrade not available",
38
+ detail: `install source is "${installSource}"; only npm installs can be auto-upgraded`,
39
+ installSource,
40
+ }));
41
+ return true;
42
+ }
43
+ const rt = getUpgradeRuntime();
44
+ if (!rt) {
45
+ res.statusCode = 500;
46
+ res.setHeader("Content-Type", "application/json");
47
+ res.end(JSON.stringify({ error: "upgrade runtime unavailable" }));
48
+ return true;
49
+ }
50
+ const spec = `${PLUGIN_PACKAGE_NAME}@latest`;
51
+ log.info(`Starting plugin upgrade: ${spec} (from ${PLUGIN_VERSION})`);
52
+ let result;
53
+ try {
54
+ result = await rt.runCommandWithTimeout(["openclaw", "plugins", "install", spec, "--force"], UPGRADE_TIMEOUT_MS);
55
+ }
56
+ catch (err) {
57
+ const msg = err instanceof Error ? err.message : String(err);
58
+ log.error(`plugin upgrade command failed to spawn: ${msg}`);
59
+ res.statusCode = 500;
60
+ res.setHeader("Content-Type", "application/json");
61
+ res.end(JSON.stringify({ error: "upgrade command failed", detail: msg }));
62
+ return true;
63
+ }
64
+ if (result.code !== 0) {
65
+ const stderrTail = (result.stderr ?? "").slice(-2000);
66
+ log.error(`plugin upgrade exited code=${result.code}: ${stderrTail}`);
67
+ res.statusCode = 500;
68
+ res.setHeader("Content-Type", "application/json");
69
+ res.end(JSON.stringify({
70
+ error: "upgrade command exited non-zero",
71
+ code: result.code,
72
+ detail: stderrTail,
73
+ }));
74
+ return true;
75
+ }
76
+ log.info("Plugin upgrade install succeeded; scheduling gateway restart");
77
+ // Respond first so the app receives confirmation before the restart drops the
78
+ // connection, then trigger the safe restart after a short flush delay.
79
+ res.statusCode = 202;
80
+ res.setHeader("Content-Type", "application/json");
81
+ res.end(JSON.stringify({ status: "upgrading", from: PLUGIN_VERSION }));
82
+ setTimeout(() => {
83
+ void rt
84
+ .mutateConfigFile({
85
+ afterWrite: { mode: "restart", reason: "friday-next 插件自动升级后重启" },
86
+ mutate: () => { },
87
+ })
88
+ .catch((err) => {
89
+ const msg = err instanceof Error ? err.message : String(err);
90
+ log.error(`gateway restart trigger failed: ${msg}`);
91
+ });
92
+ }, RESTART_DELAY_MS).unref?.();
93
+ return true;
94
+ }
@@ -3,6 +3,7 @@ import { getHostOpenClawConfigSnapshot } from "../../host-config.js";
3
3
  import { getFridayNextRuntime } from "../../runtime.js";
4
4
  import { sseEmitter } from "../../sse/emitter.js";
5
5
  import { extractBearerToken } from "../middleware/auth.js";
6
+ import { PLUGIN_VERSION } from "../../version.js";
6
7
  function parseLastEventId(req, url) {
7
8
  const query = Number.parseInt(url.searchParams.get("lastEventId") ?? "", 10);
8
9
  if (Number.isFinite(query))
@@ -48,6 +49,7 @@ export async function handleSseStream(req, res) {
48
49
  deviceId: normalized,
49
50
  serverTime: Date.now(),
50
51
  lastSeq,
52
+ pluginVersion: PLUGIN_VERSION,
51
53
  },
52
54
  }, deviceId, true);
53
55
  const lastEventId = parseLastEventId(req, url);
@@ -1,6 +1,7 @@
1
1
  import { getActiveRunIds } from "../../agent/active-runs.js";
2
2
  import { sseEmitter } from "../../sse/emitter.js";
3
3
  import { extractBearerToken } from "../middleware/auth.js";
4
+ import { PLUGIN_VERSION } from "../../version.js";
4
5
  export async function handleStatus(req, res) {
5
6
  if (req.method !== "GET") {
6
7
  res.statusCode = 405;
@@ -21,6 +22,7 @@ export async function handleStatus(req, res) {
21
22
  ok: true,
22
23
  channel: "friday-next",
23
24
  version: "v2",
25
+ pluginVersion: PLUGIN_VERSION,
24
26
  connections: sseEmitter.getConnectionCount(),
25
27
  activeRuns,
26
28
  activeRunCount: activeRuns.length,
@@ -19,6 +19,8 @@ import { handleHistoryMessages } from "./handlers/history-messages.js";
19
19
  import { handleHistorySetTitle } from "./handlers/history-set-title.js";
20
20
  import { handleStatus } from "./handlers/status.js";
21
21
  import { handleHealth } from "./handlers/health.js";
22
+ import { handlePluginInfo } from "./handlers/plugin-info.js";
23
+ import { handlePluginUpgrade } from "./handlers/plugin-upgrade.js";
22
24
  import { applyCorsHeaders } from "./middleware/cors.js";
23
25
  import { resolveFridayNextConfig } from "../config.js";
24
26
  import { getHostOpenClawConfigSnapshot } from "../host-config.js";
@@ -87,6 +89,14 @@ async function handleFridayNextRoute(req, res) {
87
89
  if (req.method === "GET" && pathname === "/friday-next/health") {
88
90
  return await handleHealth(req, res);
89
91
  }
92
+ // Route: GET /friday-next/plugin/info (current/latest version + upgradability)
93
+ if (req.method === "GET" && pathname === "/friday-next/plugin/info") {
94
+ return await handlePluginInfo(req, res);
95
+ }
96
+ // Route: POST /friday-next/plugin/upgrade (npm install @latest + safe gateway restart)
97
+ if (req.method === "POST" && pathname === "/friday-next/plugin/upgrade") {
98
+ return await handlePluginUpgrade(req, res);
99
+ }
90
100
  // Not found
91
101
  return false;
92
102
  }
@@ -0,0 +1,15 @@
1
+ /** Install source from the OpenClaw config `plugins.installs[<id>].source`. */
2
+ export type InstallSource = "npm" | "path" | "archive" | "clawhub" | "git" | "marketplace" | "unknown";
3
+ /**
4
+ * Read the install source for this plugin from the live config snapshot.
5
+ * Returns "unknown" when the record can't be resolved (e.g. runtime not captured).
6
+ * Only "npm" is auto-upgradable; "path" means a dev (load.paths) install which must
7
+ * never be npm-upgraded (would duplicate-install and break agent media sends).
8
+ */
9
+ export declare function getInstallSource(): InstallSource;
10
+ /** Compare dotted numeric versions. Returns true if `a` is strictly greater than `b`. */
11
+ export declare function semverGreater(a: string | null | undefined, b: string | null | undefined): boolean;
12
+ /** Fetch the latest published version from the npm registry (cached ~10min). Null on failure. */
13
+ export declare function fetchLatestVersion(nowMs: number): Promise<string | null>;
14
+ /** Vitest-only */
15
+ export declare function resetLatestVersionCacheForTest(): void;
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Helpers for the plugin upgrade feature: read this plugin's install record
3
+ * (source: "npm" vs "path"/dev), compare semver, and look up the latest version
4
+ * published to the npm registry (cached).
5
+ */
6
+ import { getUpgradeRuntime } from "./upgrade-runtime.js";
7
+ import { PLUGIN_ID, PLUGIN_PACKAGE_NAME } from "./version.js";
8
+ /**
9
+ * Read the install source for this plugin from the live config snapshot.
10
+ * Returns "unknown" when the record can't be resolved (e.g. runtime not captured).
11
+ * Only "npm" is auto-upgradable; "path" means a dev (load.paths) install which must
12
+ * never be npm-upgraded (would duplicate-install and break agent media sends).
13
+ */
14
+ export function getInstallSource() {
15
+ const rt = getUpgradeRuntime();
16
+ if (!rt)
17
+ return "unknown";
18
+ try {
19
+ const cfg = rt.currentConfig();
20
+ const source = cfg?.plugins?.installs?.[PLUGIN_ID]?.source;
21
+ if (source === "npm" ||
22
+ source === "path" ||
23
+ source === "archive" ||
24
+ source === "clawhub" ||
25
+ source === "git" ||
26
+ source === "marketplace") {
27
+ return source;
28
+ }
29
+ return "unknown";
30
+ }
31
+ catch {
32
+ return "unknown";
33
+ }
34
+ }
35
+ /** Compare dotted numeric versions. Returns true if `a` is strictly greater than `b`. */
36
+ export function semverGreater(a, b) {
37
+ if (!a || !b)
38
+ return false;
39
+ const pa = parseSemver(a);
40
+ const pb = parseSemver(b);
41
+ for (let i = 0; i < 3; i++) {
42
+ if (pa[i] > pb[i])
43
+ return true;
44
+ if (pa[i] < pb[i])
45
+ return false;
46
+ }
47
+ return false;
48
+ }
49
+ function parseSemver(v) {
50
+ // Strip a leading "v" and any pre-release/build suffix, keep major.minor.patch.
51
+ const core = v.trim().replace(/^v/i, "").split(/[-+]/)[0];
52
+ const parts = core.split(".").map((p) => Number.parseInt(p, 10));
53
+ return [parts[0] || 0, parts[1] || 0, parts[2] || 0];
54
+ }
55
+ let cachedLatest = null;
56
+ const LATEST_TTL_MS = 10 * 60 * 1000;
57
+ /** Fetch the latest published version from the npm registry (cached ~10min). Null on failure. */
58
+ export async function fetchLatestVersion(nowMs) {
59
+ if (cachedLatest && nowMs - cachedLatest.fetchedAt < LATEST_TTL_MS) {
60
+ return cachedLatest.version;
61
+ }
62
+ let version = null;
63
+ try {
64
+ const controller = new AbortController();
65
+ const timer = setTimeout(() => controller.abort(), 5000);
66
+ try {
67
+ const res = await fetch(`https://registry.npmjs.org/${PLUGIN_PACKAGE_NAME}/latest`, { signal: controller.signal, headers: { Accept: "application/json" } });
68
+ if (res.ok) {
69
+ const body = (await res.json());
70
+ if (typeof body.version === "string" && body.version)
71
+ version = body.version;
72
+ }
73
+ }
74
+ finally {
75
+ clearTimeout(timer);
76
+ }
77
+ }
78
+ catch {
79
+ version = null;
80
+ }
81
+ cachedLatest = { version, fetchedAt: nowMs };
82
+ return version;
83
+ }
84
+ /** Vitest-only */
85
+ export function resetLatestVersionCacheForTest() {
86
+ cachedLatest = null;
87
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Captures the full plugin runtime (`api.runtime`) at `registerFull` time so the
3
+ * plugin-info / plugin-upgrade HTTP handlers can reach `system.runCommandWithTimeout`
4
+ * and `config.mutateConfigFile` — capabilities the narrow `getFridayNextRuntime()`
5
+ * store does NOT expose (it only carries `config`/`logger`).
6
+ *
7
+ * Mirrors the `setFridayAgentForwardRuntime` capture pattern in agent-forward-runtime.ts.
8
+ */
9
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
10
+ export type SpawnResultLike = {
11
+ code: number | null;
12
+ stdout: string;
13
+ stderr: string;
14
+ [key: string]: unknown;
15
+ };
16
+ export type ConfigAfterWrite = {
17
+ mode: "auto";
18
+ } | {
19
+ mode: "restart";
20
+ reason: string;
21
+ } | {
22
+ mode: "none";
23
+ reason: string;
24
+ };
25
+ export type UpgradeRuntime = {
26
+ /** Run a command (argv) with a timeout in ms; resolves with stdout/stderr/code. */
27
+ runCommandWithTimeout: (argv: string[], timeoutMs: number) => Promise<SpawnResultLike>;
28
+ /** Read the current (deep-readonly) OpenClaw config snapshot. */
29
+ currentConfig: () => unknown;
30
+ /** Mutate the config file; `afterWrite: { mode: "restart" }` triggers a safe gateway restart. */
31
+ mutateConfigFile: (params: {
32
+ afterWrite: ConfigAfterWrite;
33
+ mutate: (draft: unknown) => unknown | void;
34
+ }) => Promise<unknown>;
35
+ };
36
+ export declare function setUpgradeRuntime(api: OpenClawPluginApi): void;
37
+ export declare function getUpgradeRuntime(): UpgradeRuntime | null;
38
+ /** Vitest-only */
39
+ export declare function resetUpgradeRuntimeForTest(): void;
@@ -0,0 +1,27 @@
1
+ let upgradeRuntime = null;
2
+ export function setUpgradeRuntime(api) {
3
+ const runtime = api.runtime;
4
+ upgradeRuntime = {
5
+ runCommandWithTimeout: async (argv, timeoutMs) => {
6
+ const run = runtime.system?.runCommandWithTimeout;
7
+ if (!run)
8
+ throw new Error("runtime.system.runCommandWithTimeout unavailable");
9
+ // `runCommandWithTimeout(argv, number | CommandOptions)` — pass the bare ms.
10
+ return run(argv, timeoutMs);
11
+ },
12
+ currentConfig: () => runtime.config.current(),
13
+ mutateConfigFile: async (params) => {
14
+ const mutate = runtime.config.mutateConfigFile;
15
+ if (!mutate)
16
+ throw new Error("runtime.config.mutateConfigFile unavailable");
17
+ return mutate(params);
18
+ },
19
+ };
20
+ }
21
+ export function getUpgradeRuntime() {
22
+ return upgradeRuntime;
23
+ }
24
+ /** Vitest-only */
25
+ export function resetUpgradeRuntimeForTest() {
26
+ upgradeRuntime = null;
27
+ }
@@ -0,0 +1,5 @@
1
+ export declare const PLUGIN_VERSION: string;
2
+ /** npm package name, used for the upgrade spec and registry lookup. */
3
+ export declare const PLUGIN_PACKAGE_NAME = "@syengup/friday-channel-next";
4
+ /** Plugin id as registered with OpenClaw (used to read the install record). */
5
+ export declare const PLUGIN_ID = "friday-next";
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Plugin self-version.
3
+ *
4
+ * Resolved at module load by reading this package's own package.json relative to
5
+ * the compiled module URL. `tsc` does NOT copy package.json into `dist/`, and a
6
+ * JSON `import` would rewrite to a non-existent `dist/package.json`, so we read
7
+ * the real file from disk and walk a couple of candidate paths. Falls back to a
8
+ * hardcoded constant if the file can't be located (keep in sync with package.json).
9
+ */
10
+ import { readFileSync } from "node:fs";
11
+ import { fileURLToPath } from "node:url";
12
+ /** Keep in sync with package.json "version" as a last-resort fallback. */
13
+ const FALLBACK_VERSION = "0.1.27";
14
+ function resolvePluginVersion() {
15
+ // dist layout: <root>/dist/src/version.js → ../../package.json = <root>/package.json
16
+ // source layout (vitest/jiti): <root>/src/version.ts → ../package.json = <root>/package.json
17
+ const candidates = ["../../package.json", "../package.json"];
18
+ for (const rel of candidates) {
19
+ try {
20
+ const path = fileURLToPath(new URL(rel, import.meta.url));
21
+ const raw = readFileSync(path, "utf8");
22
+ const pkg = JSON.parse(raw);
23
+ if (pkg.name === "@syengup/friday-channel-next" && typeof pkg.version === "string" && pkg.version) {
24
+ return pkg.version;
25
+ }
26
+ }
27
+ catch {
28
+ // try next candidate
29
+ }
30
+ }
31
+ return FALLBACK_VERSION;
32
+ }
33
+ export const PLUGIN_VERSION = resolvePluginVersion();
34
+ /** npm package name, used for the upgrade spec and registry lookup. */
35
+ export const PLUGIN_PACKAGE_NAME = "@syengup/friday-channel-next";
36
+ /** Plugin id as registered with OpenClaw (used to read the install record). */
37
+ export const PLUGIN_ID = "friday-next";
package/index.ts CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  resolveFridayDeviceIdForSessionKey,
16
16
  } from "./src/friday-session.js";
17
17
  import { setFridayAgentForwardRuntime } from "./src/agent-forward-runtime.js";
18
+ import { setUpgradeRuntime } from "./src/upgrade-runtime.js";
18
19
  import { getOpenClawAgentRunContext } from "./src/agent-run-context-bridge.js";
19
20
  import { accumulateRunUsage } from "./src/agent/run-usage-accumulator.js";
20
21
  import { createFridayNextLogger } from "./src/logging.js";
@@ -86,6 +87,7 @@ export default defineChannelPluginEntry({
86
87
  setRuntime: setFridayNextRuntime,
87
88
  registerFull: (api: OpenClawPluginApi) => {
88
89
  setFridayAgentForwardRuntime(api);
90
+ setUpgradeRuntime(api);
89
91
  const sameApi = lastApiRoutesRegistered?.deref() === api;
90
92
  if (!sameApi) {
91
93
  lastApiRoutesRegistered = new WeakRef(api);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syengup/friday-channel-next",
3
- "version": "0.1.25",
3
+ "version": "0.1.27",
4
4
  "description": "OpenClaw Friday Next Apple channel plugin",
5
5
  "license": "MIT",
6
6
  "type": "module",