@syengup/friday-channel-next 0.1.37 → 0.1.39

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.
@@ -0,0 +1,19 @@
1
+ type ScopeLike = {
2
+ client?: {
3
+ connect?: {
4
+ scopes?: unknown;
5
+ };
6
+ };
7
+ } | null | undefined;
8
+ /**
9
+ * Adds the required operator scopes to a gateway-request-scope's
10
+ * `client.connect.scopes` array in place. Pure and idempotent.
11
+ * Returns the scopes that were actually added (empty if none / no array present).
12
+ */
13
+ export declare function elevateScopeForSubagentSpawn(scope: ScopeLike): string[];
14
+ /**
15
+ * Fetches the live plugin gateway-request-scope and elevates it so the dispatched
16
+ * agent can spawn subagents. Never throws — returns the scopes added (or []).
17
+ */
18
+ export declare function ensureSubagentSpawnScope(): string[];
19
+ export {};
@@ -0,0 +1,54 @@
1
+ // Operator-scope elevation for friday-next agent dispatch.
2
+ //
3
+ // Why this exists: friday-next registers all its routes with auth:"plugin" (it does
4
+ // its own device-token auth, not the gateway operator token). Core's
5
+ // createPluginRouteRuntimeScope gives auth!="gateway" routes an EMPTY operator-scope
6
+ // set. When an agent dispatched from such a route spawns a subagent, the spawn
7
+ // re-enters the in-process gateway `agent` method, which requires `operator.write`
8
+ // (core-descriptors: { name:"agent", scope:"operator.write" }). With an empty ambient
9
+ // scope the spawn fails with `{"error":"missing scope: operator.write"}`.
10
+ //
11
+ // The subagent spawn reads getPluginRuntimeGatewayRequestScope() at spawn time and
12
+ // uses that scope's client for authorization. Because AsyncLocalStorage returns the
13
+ // SAME store object reference, mutating its client.connect.scopes once — before we
14
+ // kick off the dispatch, while still inside the route's ALS context — propagates the
15
+ // elevated scopes to every later reader, including the subagent spawn.
16
+ //
17
+ // Subagent lifecycle admin methods (sessions.patch/delete) are unaffected: core pins
18
+ // those to ADMIN_SCOPE via a synthetic client, so only the `agent` method depends on
19
+ // this ambient operator.write. See memory: subagent-spawn-missing-operator-write.
20
+ import { getPluginRuntimeGatewayRequestScope } from "openclaw/plugin-sdk/plugin-runtime";
21
+ /** Operator scopes the friday-next dispatch needs so agents can spawn subagents. */
22
+ const REQUIRED_OPERATOR_SCOPES = ["operator.write", "operator.read"];
23
+ /**
24
+ * Adds the required operator scopes to a gateway-request-scope's
25
+ * `client.connect.scopes` array in place. Pure and idempotent.
26
+ * Returns the scopes that were actually added (empty if none / no array present).
27
+ */
28
+ export function elevateScopeForSubagentSpawn(scope) {
29
+ const connect = scope?.client?.connect;
30
+ if (!connect || !Array.isArray(connect.scopes)) {
31
+ return [];
32
+ }
33
+ const scopes = connect.scopes;
34
+ const added = [];
35
+ for (const scopeName of REQUIRED_OPERATOR_SCOPES) {
36
+ if (!scopes.includes(scopeName)) {
37
+ scopes.push(scopeName);
38
+ added.push(scopeName);
39
+ }
40
+ }
41
+ return added;
42
+ }
43
+ /**
44
+ * Fetches the live plugin gateway-request-scope and elevates it so the dispatched
45
+ * agent can spawn subagents. Never throws — returns the scopes added (or []).
46
+ */
47
+ export function ensureSubagentSpawnScope() {
48
+ try {
49
+ return elevateScopeForSubagentSpawn(getPluginRuntimeGatewayRequestScope());
50
+ }
51
+ catch {
52
+ return [];
53
+ }
54
+ }
@@ -21,6 +21,7 @@ import { registerFridaySessionDeviceMapping } from "../../friday-session.js";
21
21
  import { touchFridayInbound } from "../../friday-inbound-stats.js";
22
22
  import { fridayAttachmentLookupKey, fridayFilesPublicUrl, readFile, rememberInboundMediaName, resolveMediaAttachment, resolveMediaUrl, } from "./files.js";
23
23
  import { runFridayDispatch } from "../../agent/dispatch-bridge.js";
24
+ import { ensureSubagentSpawnScope } from "../../agent/operator-scope.js";
24
25
  import { saveInboundMediaBuffer } from "../../agent/media-bridge.js";
25
26
  import { contextTokensFromUsageRecord, getRunMetadata, getRunRoute, hasRunFinalDelivered, markRunFinalDelivered, registerRunRoute, setRunMetadata, } from "../../run-metadata.js";
26
27
  import { createFridayNextLogger, setFridayNextLogLevel } from "../../logging.js";
@@ -506,6 +507,14 @@ export async function handleMessages(req, res) {
506
507
  sseEmitter.untrackRun(runId);
507
508
  }
508
509
  };
510
+ // Elevate the route's (empty) operator scope so the dispatched agent can spawn
511
+ // subagents. Must run here, synchronously inside the route's AsyncLocalStorage
512
+ // context, so the live scope object the subagent spawn later reads carries
513
+ // operator.write. See agent/operator-scope.ts.
514
+ const elevatedScopes = ensureSubagentSpawnScope();
515
+ if (elevatedScopes.length > 0) {
516
+ log("SCOPE_ELEVATED", normalizedDeviceId, runId, elevatedScopes.join(","));
517
+ }
509
518
  runAgent().catch((err) => {
510
519
  log("RUN_ERROR", normalizedDeviceId, runId, String(err), "error");
511
520
  sseEmitter.untrackRun(runId);
@@ -17,12 +17,19 @@
17
17
  * in another agent's workspace (main is just another agent, not a shared pool)
18
18
  * - installed : managed skills dir (`<configDir>/skills`, sibling of the workspace)
19
19
  * - built-in : bundled core skills (`<openclaw>/skills`)
20
- * - extra : skills from ENABLED extensions (`<openclaw>/dist/extensions/<ext>/skills`,
21
- * gated by `plugins.allow`/`entries.enabled` like ControlUI) + config
22
- * `skills.load.extraDirs` mirrors ControlUI's "EXTRA" bucket
23
- * (core tags extension skills `source: "extension"`).
20
+ * - extra : everything ControlUI buckets as "EXTRA" —
21
+ * `<configDir>/plugin-skills/` : OpenClaw's symlink dir where every ACTIVATED
22
+ * plugin's skills land, incl. third-party plugins (e.g. the miloco-* set)
23
+ * `<workspace>/.agents/skills` : project-level agents skills (target agent)
24
+ * • `~/.agents/skills` : personal agents skills
25
+ * • config `skills.load.extraDirs`
26
+ * • ENABLED bundled extensions (`<openclaw>/dist/extensions/<ext>/skills`,
27
+ * gated by `plugins.allow`/`entries.enabled`) — now mostly redundant with
28
+ * plugin-skills, kept as a belt-and-suspenders fallback
24
29
  *
25
30
  * Dedup is by skill id, first source wins (workspace > installed > extra > built-in).
31
+ * The walk FOLLOWS symlinked directories — plugin-skills entries are symlinks, so without
32
+ * this the entire plugin-provided set is invisible.
26
33
  *
27
34
  * A skill's id is the `name:` field in its `SKILL.md` frontmatter (falling back to
28
35
  * the containing dir name) — NOT the dir name itself, which often differs (e.g. the
@@ -17,12 +17,19 @@
17
17
  * in another agent's workspace (main is just another agent, not a shared pool)
18
18
  * - installed : managed skills dir (`<configDir>/skills`, sibling of the workspace)
19
19
  * - built-in : bundled core skills (`<openclaw>/skills`)
20
- * - extra : skills from ENABLED extensions (`<openclaw>/dist/extensions/<ext>/skills`,
21
- * gated by `plugins.allow`/`entries.enabled` like ControlUI) + config
22
- * `skills.load.extraDirs` mirrors ControlUI's "EXTRA" bucket
23
- * (core tags extension skills `source: "extension"`).
20
+ * - extra : everything ControlUI buckets as "EXTRA" —
21
+ * `<configDir>/plugin-skills/` : OpenClaw's symlink dir where every ACTIVATED
22
+ * plugin's skills land, incl. third-party plugins (e.g. the miloco-* set)
23
+ * `<workspace>/.agents/skills` : project-level agents skills (target agent)
24
+ * • `~/.agents/skills` : personal agents skills
25
+ * • config `skills.load.extraDirs`
26
+ * • ENABLED bundled extensions (`<openclaw>/dist/extensions/<ext>/skills`,
27
+ * gated by `plugins.allow`/`entries.enabled`) — now mostly redundant with
28
+ * plugin-skills, kept as a belt-and-suspenders fallback
24
29
  *
25
30
  * Dedup is by skill id, first source wins (workspace > installed > extra > built-in).
31
+ * The walk FOLLOWS symlinked directories — plugin-skills entries are symlinks, so without
32
+ * this the entire plugin-provided set is invisible.
26
33
  *
27
34
  * A skill's id is the `name:` field in its `SKILL.md` frontmatter (falling back to
28
35
  * the containing dir name) — NOT the dir name itself, which often differs (e.g. the
@@ -36,6 +43,7 @@
36
43
  * `skills[]` config, already returned separately by the config view.
37
44
  */
38
45
  import fs from "node:fs";
46
+ import os from "node:os";
39
47
  import path from "node:path";
40
48
  import { createRequire } from "node:module";
41
49
  import { getFridayAgentForwardRuntime } from "./agent-forward-runtime.js";
@@ -100,9 +108,24 @@ function collectSkills(root, source, out, depth = 0) {
100
108
  return; // a skill is a leaf — don't treat its internals as nested skills
101
109
  }
102
110
  for (const e of entries) {
103
- if (e.isDirectory() && !e.name.startsWith(".") && !IGNORED_WALK_DIRS.has(e.name)) {
104
- collectSkills(path.join(root, e.name), source, out, depth + 1);
111
+ if (e.name.startsWith(".") || IGNORED_WALK_DIRS.has(e.name))
112
+ continue;
113
+ const full = path.join(root, e.name);
114
+ // Follow symlinked dirs: `readdirSync(withFileTypes)` reports a symlink as
115
+ // `isSymbolicLink()` (never `isDirectory()`), and OpenClaw publishes plugin
116
+ // skills as symlinks under `<configDir>/plugin-skills/` — so without this the
117
+ // entire plugin-skills (e.g. the miloco-* set) source would be silently skipped.
118
+ let isDir = e.isDirectory();
119
+ if (!isDir && e.isSymbolicLink()) {
120
+ try {
121
+ isDir = fs.statSync(full).isDirectory();
122
+ }
123
+ catch {
124
+ isDir = false; // broken/unreadable symlink
125
+ }
105
126
  }
127
+ if (isDir)
128
+ collectSkills(full, source, out, depth + 1);
106
129
  }
107
130
  }
108
131
  let cachedOpenClawRoot;
@@ -216,24 +239,44 @@ export function discoverAvailableSkills(cfg, agentId) {
216
239
  // leaked main's skills into every other agent's catalog.
217
240
  try {
218
241
  const ws = resolveWs(cfg, agentId);
219
- if (ws)
242
+ if (ws) {
220
243
  sources.push({ dir: path.join(ws, "skills"), source: "workspace" });
244
+ // Project-level agents skills: `<workspace>/.agents/skills` (core source
245
+ // `agents-skills-project`), scoped to this same target agent's workspace.
246
+ sources.push({ dir: path.join(ws, ".agents", "skills"), source: "extra" });
247
+ }
221
248
  }
222
249
  catch {
223
250
  // skip unresolvable workspace
224
251
  }
225
- // Managed skills dir: `<configDir>/skills`. It is agent-independent; anchor it off the
226
- // DEFAULT agent's workspace parent (the default workspace lives directly under configDir,
227
- // whereas non-default workspaces may be nested under it).
252
+ // Managed (`<configDir>/skills`) + plugin-skills (`<configDir>/plugin-skills`) dirs. Both are
253
+ // agent-independent; anchor off the DEFAULT agent's workspace parent (the default workspace
254
+ // lives directly under configDir, whereas non-default workspaces may be nested under it).
228
255
  try {
229
256
  const defaultWs = resolveWs(cfg, resolveDefaultAgentId(c));
230
- if (defaultWs)
231
- sources.push({ dir: path.join(path.dirname(defaultWs), "skills"), source: "installed" });
257
+ if (defaultWs) {
258
+ const configDir = path.dirname(defaultWs);
259
+ sources.push({ dir: path.join(configDir, "skills"), source: "installed" });
260
+ // OpenClaw symlinks every ACTIVATED plugin's skills (incl. third-party plugins under
261
+ // `<configDir>/extensions/*`, e.g. the miloco-* set) into `<configDir>/plugin-skills/`
262
+ // and loads it as an "extra" source. The old dist/extensions scan only caught BUNDLED
263
+ // extensions, so plugin-provided skills were missing entirely.
264
+ sources.push({ dir: path.join(configDir, "plugin-skills"), source: "extra" });
265
+ }
232
266
  }
233
267
  catch {
234
268
  // skip unresolvable managed dir
235
269
  }
236
270
  }
271
+ // Personal agents skills: `~/.agents/skills` (core source `agents-skills-personal`).
272
+ try {
273
+ const home = os.homedir();
274
+ if (home)
275
+ sources.push({ dir: path.join(home, ".agents", "skills"), source: "extra" });
276
+ }
277
+ catch {
278
+ // home dir unresolvable on this platform — skip
279
+ }
237
280
  const extraDirs = c?.skills?.load?.extraDirs;
238
281
  if (Array.isArray(extraDirs)) {
239
282
  for (const d of extraDirs)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syengup/friday-channel-next",
3
- "version": "0.1.37",
3
+ "version": "0.1.39",
4
4
  "description": "OpenClaw Friday Next Apple channel plugin",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -12,19 +12,6 @@
12
12
  "tsconfig.json",
13
13
  "openclaw.plugin.json"
14
14
  ],
15
- "scripts": {
16
- "build": "tsc -p tsconfig.json",
17
- "lint": "eslint .",
18
- "lint:fix": "eslint . --fix",
19
- "format": "prettier --write .",
20
- "format:check": "prettier --check .",
21
- "prepublishOnly": "pnpm build && rm -rf dist/attachments",
22
- "test": "npm run test:unit && npm run test:e2e",
23
- "test:unit": "vitest run",
24
- "test:e2e": "vitest run --config vitest.e2e.config.ts",
25
- "test:smoke": "node scripts/e2e-smoke.mjs",
26
- "test:msg-live": "node scripts/message-roundtrip-live.mjs"
27
- },
28
15
  "bin": {
29
16
  "friday-channel-next": "install.js"
30
17
  },
@@ -75,5 +62,17 @@
75
62
  "typescript-eslint": "^8.61.1",
76
63
  "vitest": "^4.1.5",
77
64
  "zod": "^4.3.6"
65
+ },
66
+ "scripts": {
67
+ "build": "tsc -p tsconfig.json",
68
+ "lint": "eslint .",
69
+ "lint:fix": "eslint . --fix",
70
+ "format": "prettier --write .",
71
+ "format:check": "prettier --check .",
72
+ "test": "npm run test:unit && npm run test:e2e",
73
+ "test:unit": "vitest run",
74
+ "test:e2e": "vitest run --config vitest.e2e.config.ts",
75
+ "test:smoke": "node scripts/e2e-smoke.mjs",
76
+ "test:msg-live": "node scripts/message-roundtrip-live.mjs"
78
77
  }
79
- }
78
+ }
@@ -0,0 +1,66 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+
3
+ // The helper imports the live scope getter from core; the pure function under test
4
+ // never calls it, but the module-level import must resolve. Mock it so the unit test
5
+ // does not depend on the OpenClaw dist runtime.
6
+ const getScope = vi.fn();
7
+ vi.mock("openclaw/plugin-sdk/plugin-runtime", () => ({
8
+ getPluginRuntimeGatewayRequestScope: () => getScope(),
9
+ }));
10
+
11
+ import { elevateScopeForSubagentSpawn, ensureSubagentSpawnScope } from "./operator-scope.js";
12
+
13
+ function makeScope(scopes: string[]) {
14
+ return { client: { connect: { role: "operator", scopes } } };
15
+ }
16
+
17
+ describe("elevateScopeForSubagentSpawn", () => {
18
+ it("adds operator.write + operator.read to an empty plugin-route scope", () => {
19
+ // friday-next routes register with auth:"plugin", which core gives EMPTY operator
20
+ // scopes. Subagent spawn re-enters the gateway `agent` method (requires
21
+ // operator.write) and fails with "missing scope: operator.write" without this.
22
+ const scope = makeScope([]);
23
+ const added = elevateScopeForSubagentSpawn(scope);
24
+ expect(scope.client.connect.scopes).toContain("operator.write");
25
+ expect(scope.client.connect.scopes).toContain("operator.read");
26
+ expect(added).toEqual(["operator.write", "operator.read"]);
27
+ });
28
+
29
+ it("is idempotent — does not duplicate already-present scopes", () => {
30
+ const scope = makeScope(["operator.write"]);
31
+ const added = elevateScopeForSubagentSpawn(scope);
32
+ expect(added).toEqual(["operator.read"]);
33
+ expect(scope.client.connect.scopes.filter((s) => s === "operator.write")).toHaveLength(1);
34
+ });
35
+
36
+ it("preserves unrelated existing scopes", () => {
37
+ const scope = makeScope(["operator.admin"]);
38
+ elevateScopeForSubagentSpawn(scope);
39
+ expect(scope.client.connect.scopes).toContain("operator.admin");
40
+ expect(scope.client.connect.scopes).toContain("operator.write");
41
+ });
42
+
43
+ it("returns [] and never throws when no scope/client is present", () => {
44
+ expect(elevateScopeForSubagentSpawn(undefined)).toEqual([]);
45
+ expect(elevateScopeForSubagentSpawn(null)).toEqual([]);
46
+ expect(elevateScopeForSubagentSpawn({})).toEqual([]);
47
+ expect(elevateScopeForSubagentSpawn({ client: { connect: {} } })).toEqual([]);
48
+ });
49
+ });
50
+
51
+ describe("ensureSubagentSpawnScope", () => {
52
+ it("elevates the live scope returned by the SDK getter", () => {
53
+ const scope = makeScope([]);
54
+ getScope.mockReturnValue(scope);
55
+ const added = ensureSubagentSpawnScope();
56
+ expect(added).toEqual(["operator.write", "operator.read"]);
57
+ expect(scope.client.connect.scopes).toContain("operator.write");
58
+ });
59
+
60
+ it("swallows errors from the getter and returns []", () => {
61
+ getScope.mockImplementation(() => {
62
+ throw new Error("no scope");
63
+ });
64
+ expect(ensureSubagentSpawnScope()).toEqual([]);
65
+ });
66
+ });
@@ -0,0 +1,63 @@
1
+ // Operator-scope elevation for friday-next agent dispatch.
2
+ //
3
+ // Why this exists: friday-next registers all its routes with auth:"plugin" (it does
4
+ // its own device-token auth, not the gateway operator token). Core's
5
+ // createPluginRouteRuntimeScope gives auth!="gateway" routes an EMPTY operator-scope
6
+ // set. When an agent dispatched from such a route spawns a subagent, the spawn
7
+ // re-enters the in-process gateway `agent` method, which requires `operator.write`
8
+ // (core-descriptors: { name:"agent", scope:"operator.write" }). With an empty ambient
9
+ // scope the spawn fails with `{"error":"missing scope: operator.write"}`.
10
+ //
11
+ // The subagent spawn reads getPluginRuntimeGatewayRequestScope() at spawn time and
12
+ // uses that scope's client for authorization. Because AsyncLocalStorage returns the
13
+ // SAME store object reference, mutating its client.connect.scopes once — before we
14
+ // kick off the dispatch, while still inside the route's ALS context — propagates the
15
+ // elevated scopes to every later reader, including the subagent spawn.
16
+ //
17
+ // Subagent lifecycle admin methods (sessions.patch/delete) are unaffected: core pins
18
+ // those to ADMIN_SCOPE via a synthetic client, so only the `agent` method depends on
19
+ // this ambient operator.write. See memory: subagent-spawn-missing-operator-write.
20
+ import { getPluginRuntimeGatewayRequestScope } from "openclaw/plugin-sdk/plugin-runtime";
21
+
22
+ /** Operator scopes the friday-next dispatch needs so agents can spawn subagents. */
23
+ const REQUIRED_OPERATOR_SCOPES = ["operator.write", "operator.read"] as const;
24
+
25
+ type ScopeLike =
26
+ | {
27
+ client?: { connect?: { scopes?: unknown } };
28
+ }
29
+ | null
30
+ | undefined;
31
+
32
+ /**
33
+ * Adds the required operator scopes to a gateway-request-scope's
34
+ * `client.connect.scopes` array in place. Pure and idempotent.
35
+ * Returns the scopes that were actually added (empty if none / no array present).
36
+ */
37
+ export function elevateScopeForSubagentSpawn(scope: ScopeLike): string[] {
38
+ const connect = scope?.client?.connect;
39
+ if (!connect || !Array.isArray(connect.scopes)) {
40
+ return [];
41
+ }
42
+ const scopes = connect.scopes as string[];
43
+ const added: string[] = [];
44
+ for (const scopeName of REQUIRED_OPERATOR_SCOPES) {
45
+ if (!scopes.includes(scopeName)) {
46
+ scopes.push(scopeName);
47
+ added.push(scopeName);
48
+ }
49
+ }
50
+ return added;
51
+ }
52
+
53
+ /**
54
+ * Fetches the live plugin gateway-request-scope and elevates it so the dispatched
55
+ * agent can spawn subagents. Never throws — returns the scopes added (or []).
56
+ */
57
+ export function ensureSubagentSpawnScope(): string[] {
58
+ try {
59
+ return elevateScopeForSubagentSpawn(getPluginRuntimeGatewayRequestScope());
60
+ } catch {
61
+ return [];
62
+ }
63
+ }
@@ -48,6 +48,7 @@ import {
48
48
  resolveMediaUrl,
49
49
  } from "./files.js";
50
50
  import { runFridayDispatch } from "../../agent/dispatch-bridge.js";
51
+ import { ensureSubagentSpawnScope } from "../../agent/operator-scope.js";
51
52
  import { saveInboundMediaBuffer } from "../../agent/media-bridge.js";
52
53
  import {
53
54
  contextTokensFromUsageRecord,
@@ -674,6 +675,15 @@ export async function handleMessages(req: IncomingMessage, res: ServerResponse):
674
675
  }
675
676
  };
676
677
 
678
+ // Elevate the route's (empty) operator scope so the dispatched agent can spawn
679
+ // subagents. Must run here, synchronously inside the route's AsyncLocalStorage
680
+ // context, so the live scope object the subagent spawn later reads carries
681
+ // operator.write. See agent/operator-scope.ts.
682
+ const elevatedScopes = ensureSubagentSpawnScope();
683
+ if (elevatedScopes.length > 0) {
684
+ log("SCOPE_ELEVATED", normalizedDeviceId, runId, elevatedScopes.join(","));
685
+ }
686
+
677
687
  runAgent().catch((err) => {
678
688
  log("RUN_ERROR", normalizedDeviceId, runId, String(err), "error");
679
689
  sseEmitter.untrackRun(runId);
package/src/openclaw.d.ts CHANGED
@@ -93,6 +93,16 @@ declare module "openclaw/plugin-sdk/reply-dispatch-runtime" {
93
93
  export const dispatchReplyWithDispatcher: (...args: any[]) => any;
94
94
  }
95
95
 
96
+ declare module "openclaw/plugin-sdk/plugin-runtime" {
97
+ /**
98
+ * Returns the request-local plugin gateway-request-scope (operator client/scopes,
99
+ * context) when called from within a plugin HTTP-route handler's async context.
100
+ */
101
+ export const getPluginRuntimeGatewayRequestScope: () =>
102
+ | { client?: { connect?: { scopes?: string[] } } }
103
+ | undefined;
104
+ }
105
+
96
106
  declare module "openclaw/plugin-sdk/status-helpers" {
97
107
  export type ChannelAccountSnapshot = any;
98
108
  }
@@ -1,4 +1,4 @@
1
- import { describe, it, expect, afterEach } from "vitest";
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
2
  import fs from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
@@ -22,6 +22,8 @@ function makeSkills(parent: string, ids: string[]): void {
22
22
 
23
23
  describe("discoverAvailableSkills", () => {
24
24
  let root: string;
25
+ let emptyHome: string;
26
+ let savedHome: string | undefined;
25
27
 
26
28
  function wire(configRoot: string, cfg: unknown): void {
27
29
  setFridayAgentForwardRuntime({
@@ -39,10 +41,21 @@ describe("discoverAvailableSkills", () => {
39
41
  } as never);
40
42
  }
41
43
 
44
+ beforeEach(() => {
45
+ // Isolate `~/.agents/skills` (a real discovery source) from the dev machine's
46
+ // home so exact-match assertions don't pick up the operator's actual skills.
47
+ savedHome = process.env.HOME;
48
+ emptyHome = fs.mkdtempSync(path.join(os.tmpdir(), "friday-home-"));
49
+ process.env.HOME = emptyHome;
50
+ });
51
+
42
52
  afterEach(() => {
53
+ if (savedHome === undefined) delete process.env.HOME;
54
+ else process.env.HOME = savedHome;
43
55
  resetFridayAgentForwardRuntimeForTest();
44
56
  resetOpenClawRootCacheForTest();
45
57
  if (root) fs.rmSync(root, { recursive: true, force: true });
58
+ if (emptyHome) fs.rmSync(emptyHome, { recursive: true, force: true });
46
59
  });
47
60
 
48
61
  it("scans only the target agent's own workspace (not the default agent's), plus managed + extra dirs, deduped and sorted", () => {
@@ -118,6 +131,42 @@ describe("discoverAvailableSkills", () => {
118
131
  expect(result.find((s) => s.id === "self-improvement")?.description).toBe("x");
119
132
  });
120
133
 
134
+ it("discovers plugin-skills (symlinks), plus project + personal agents skills as 'extra'", () => {
135
+ root = fs.mkdtempSync(path.join(os.tmpdir(), "friday-disc-"));
136
+ const configRoot = path.join(root, "configdir");
137
+ const operatorWs = path.join(configRoot, "workspace", "agents", "operator");
138
+
139
+ // target agent's own workspace skills
140
+ makeSkills(path.join(operatorWs, "skills"), ["own-skill"]);
141
+ // project-level agents skills: <workspace>/.agents/skills
142
+ makeSkills(path.join(operatorWs, ".agents", "skills"), ["project-skill"]);
143
+ // personal agents skills: <home>/.agents/skills (HOME isolated in beforeEach)
144
+ makeSkills(path.join(emptyHome, ".agents", "skills"), ["personal-skill"]);
145
+
146
+ // plugin-skills: real plugin skill dirs SYMLINKED into <configDir>/plugin-skills/,
147
+ // exactly how OpenClaw publishes third-party plugin skills (e.g. the miloco-* set).
148
+ const pluginPkg = path.join(root, "miloco-openclaw-plugin", "skills");
149
+ makeSkills(pluginPkg, ["miloco-devices", "miloco-notify"]);
150
+ const pluginSkillsDir = path.join(configRoot, "plugin-skills");
151
+ fs.mkdirSync(pluginSkillsDir, { recursive: true });
152
+ for (const id of ["miloco-devices", "miloco-notify"]) {
153
+ fs.symlinkSync(path.join(pluginPkg, id), path.join(pluginSkillsDir, id), "dir");
154
+ }
155
+
156
+ const cfg = { agents: { list: [{ id: "main", default: true }, { id: "operator" }] } };
157
+ wire(configRoot, cfg);
158
+
159
+ const result = discoverAvailableSkills(cfg, "operator");
160
+ const bySource = Object.fromEntries(result.map((s) => [s.id, s.source]));
161
+ expect(bySource).toEqual({
162
+ "own-skill": "workspace",
163
+ "project-skill": "extra",
164
+ "personal-skill": "extra",
165
+ "miloco-devices": "extra", // followed through the symlink — the miloco regression
166
+ "miloco-notify": "extra",
167
+ });
168
+ });
169
+
121
170
  it("returns [] without throwing when nothing is resolvable", () => {
122
171
  resetFridayAgentForwardRuntimeForTest();
123
172
  expect(discoverAvailableSkills({}, "main")).toEqual([]);
@@ -17,12 +17,19 @@
17
17
  * in another agent's workspace (main is just another agent, not a shared pool)
18
18
  * - installed : managed skills dir (`<configDir>/skills`, sibling of the workspace)
19
19
  * - built-in : bundled core skills (`<openclaw>/skills`)
20
- * - extra : skills from ENABLED extensions (`<openclaw>/dist/extensions/<ext>/skills`,
21
- * gated by `plugins.allow`/`entries.enabled` like ControlUI) + config
22
- * `skills.load.extraDirs` mirrors ControlUI's "EXTRA" bucket
23
- * (core tags extension skills `source: "extension"`).
20
+ * - extra : everything ControlUI buckets as "EXTRA" —
21
+ * `<configDir>/plugin-skills/` : OpenClaw's symlink dir where every ACTIVATED
22
+ * plugin's skills land, incl. third-party plugins (e.g. the miloco-* set)
23
+ * `<workspace>/.agents/skills` : project-level agents skills (target agent)
24
+ * • `~/.agents/skills` : personal agents skills
25
+ * • config `skills.load.extraDirs`
26
+ * • ENABLED bundled extensions (`<openclaw>/dist/extensions/<ext>/skills`,
27
+ * gated by `plugins.allow`/`entries.enabled`) — now mostly redundant with
28
+ * plugin-skills, kept as a belt-and-suspenders fallback
24
29
  *
25
30
  * Dedup is by skill id, first source wins (workspace > installed > extra > built-in).
31
+ * The walk FOLLOWS symlinked directories — plugin-skills entries are symlinks, so without
32
+ * this the entire plugin-provided set is invisible.
26
33
  *
27
34
  * A skill's id is the `name:` field in its `SKILL.md` frontmatter (falling back to
28
35
  * the containing dir name) — NOT the dir name itself, which often differs (e.g. the
@@ -37,6 +44,7 @@
37
44
  */
38
45
 
39
46
  import fs from "node:fs";
47
+ import os from "node:os";
40
48
  import path from "node:path";
41
49
  import { createRequire } from "node:module";
42
50
  import { getFridayAgentForwardRuntime } from "./agent-forward-runtime.js";
@@ -107,9 +115,21 @@ function collectSkills(
107
115
  return; // a skill is a leaf — don't treat its internals as nested skills
108
116
  }
109
117
  for (const e of entries) {
110
- if (e.isDirectory() && !e.name.startsWith(".") && !IGNORED_WALK_DIRS.has(e.name)) {
111
- collectSkills(path.join(root, e.name), source, out, depth + 1);
118
+ if (e.name.startsWith(".") || IGNORED_WALK_DIRS.has(e.name)) continue;
119
+ const full = path.join(root, e.name);
120
+ // Follow symlinked dirs: `readdirSync(withFileTypes)` reports a symlink as
121
+ // `isSymbolicLink()` (never `isDirectory()`), and OpenClaw publishes plugin
122
+ // skills as symlinks under `<configDir>/plugin-skills/` — so without this the
123
+ // entire plugin-skills (e.g. the miloco-* set) source would be silently skipped.
124
+ let isDir = e.isDirectory();
125
+ if (!isDir && e.isSymbolicLink()) {
126
+ try {
127
+ isDir = fs.statSync(full).isDirectory();
128
+ } catch {
129
+ isDir = false; // broken/unreadable symlink
130
+ }
112
131
  }
132
+ if (isDir) collectSkills(full, source, out, depth + 1);
113
133
  }
114
134
  }
115
135
 
@@ -227,22 +247,42 @@ export function discoverAvailableSkills(cfg: unknown, agentId: string): Discover
227
247
  // leaked main's skills into every other agent's catalog.
228
248
  try {
229
249
  const ws = resolveWs(cfg, agentId);
230
- if (ws) sources.push({ dir: path.join(ws, "skills"), source: "workspace" });
250
+ if (ws) {
251
+ sources.push({ dir: path.join(ws, "skills"), source: "workspace" });
252
+ // Project-level agents skills: `<workspace>/.agents/skills` (core source
253
+ // `agents-skills-project`), scoped to this same target agent's workspace.
254
+ sources.push({ dir: path.join(ws, ".agents", "skills"), source: "extra" });
255
+ }
231
256
  } catch {
232
257
  // skip unresolvable workspace
233
258
  }
234
- // Managed skills dir: `<configDir>/skills`. It is agent-independent; anchor it off the
235
- // DEFAULT agent's workspace parent (the default workspace lives directly under configDir,
236
- // whereas non-default workspaces may be nested under it).
259
+ // Managed (`<configDir>/skills`) + plugin-skills (`<configDir>/plugin-skills`) dirs. Both are
260
+ // agent-independent; anchor off the DEFAULT agent's workspace parent (the default workspace
261
+ // lives directly under configDir, whereas non-default workspaces may be nested under it).
237
262
  try {
238
263
  const defaultWs = resolveWs(cfg, resolveDefaultAgentId(c));
239
- if (defaultWs)
240
- sources.push({ dir: path.join(path.dirname(defaultWs), "skills"), source: "installed" });
264
+ if (defaultWs) {
265
+ const configDir = path.dirname(defaultWs);
266
+ sources.push({ dir: path.join(configDir, "skills"), source: "installed" });
267
+ // OpenClaw symlinks every ACTIVATED plugin's skills (incl. third-party plugins under
268
+ // `<configDir>/extensions/*`, e.g. the miloco-* set) into `<configDir>/plugin-skills/`
269
+ // and loads it as an "extra" source. The old dist/extensions scan only caught BUNDLED
270
+ // extensions, so plugin-provided skills were missing entirely.
271
+ sources.push({ dir: path.join(configDir, "plugin-skills"), source: "extra" });
272
+ }
241
273
  } catch {
242
274
  // skip unresolvable managed dir
243
275
  }
244
276
  }
245
277
 
278
+ // Personal agents skills: `~/.agents/skills` (core source `agents-skills-personal`).
279
+ try {
280
+ const home = os.homedir();
281
+ if (home) sources.push({ dir: path.join(home, ".agents", "skills"), source: "extra" });
282
+ } catch {
283
+ // home dir unresolvable on this platform — skip
284
+ }
285
+
246
286
  const extraDirs = (
247
287
  (c?.skills as Record<string, unknown> | undefined)?.load as Record<string, unknown> | undefined
248
288
  )?.extraDirs;