openwork-server 0.18.60 → 0.18.62

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 (81) hide show
  1. package/README.md +9 -2
  2. package/bin/openwork-server.mjs +1 -1
  3. package/bin/platform.mjs +7 -7
  4. package/dist/{opencode-plugin-deps-1.18.30.tgz → opencode-plugin-deps-1.18.33.tgz} +0 -0
  5. package/dist/opencode-plugins/drive-file-tools.js +10 -8
  6. package/dist/opencode-plugins/openwork-cloud-skills-v2.js +87 -41
  7. package/dist/opencode-plugins/openwork-context-v2.js +67 -35
  8. package/dist/opencode-plugins/openwork-extensions-preview.js +9 -7
  9. package/dist/opencode-plugins/openwork-gateway-quota-v2.js +187 -0
  10. package/dist/opencode-plugins/openwork-gateway-quota.js +232 -23
  11. package/dist/opencode-plugins/openwork-provider-sync-v2.js +85 -2
  12. package/dist/openwork-server.mjs +1234 -371
  13. package/package.json +2 -2
  14. package/web/assets/{abnfDiagram-VCTEODGH-D0_4pXEx.js → abnfDiagram-VCTEODGH-DJzrT8Ql.js} +1 -1
  15. package/web/assets/app-CkmqBm_1.css +32 -0
  16. package/web/assets/{app-BCXQnJUH.js → app-_2KkkZIM.js} +435 -425
  17. package/web/assets/{arc-BSTwaImw.js → arc-KehB1xN5.js} +1 -1
  18. package/web/assets/{architectureDiagram-5GKGNRK7-cXwF0iP0.js → architectureDiagram-5GKGNRK7-C_GL2639.js} +1 -1
  19. package/web/assets/{artifact-code-view-CUXv3CYK.js → artifact-code-view-CYn_58gV.js} +1 -1
  20. package/web/assets/{artifact-spreadsheet-editor-CxhNYmjP.js → artifact-spreadsheet-editor-f6V-tYvi.js} +1 -1
  21. package/web/assets/{artifact-text-editor-k2TGQI-i.js → artifact-text-editor-CiSlW3kl.js} +1 -1
  22. package/web/assets/{blockDiagram-OSKFZWR5-Cl9p_z8u.js → blockDiagram-OSKFZWR5-DErH-7gY.js} +1 -1
  23. package/web/assets/{c4Diagram-7LVT6UL2-YQjnoUQ0.js → c4Diagram-7LVT6UL2-aML4z_n9.js} +1 -1
  24. package/web/assets/channel-7oM0ncHI.js +1 -0
  25. package/web/assets/{chunk-2Q5K7J3B-YZaSwlzj.js → chunk-2Q5K7J3B-Dw_IH9rW.js} +1 -1
  26. package/web/assets/{chunk-5VM5RSS4-BICC_wIw.js → chunk-5VM5RSS4-aeJEQ945.js} +1 -1
  27. package/web/assets/{chunk-F27PBJKO-CBxmcmxs.js → chunk-F27PBJKO-hZwti-Oc.js} +1 -1
  28. package/web/assets/{chunk-HLEWEB6X-2h2z7zt8.js → chunk-HLEWEB6X-JFNzm3Ge.js} +1 -1
  29. package/web/assets/{chunk-JWPE2WC7-BnAMOpXl.js → chunk-JWPE2WC7-B34hvYk7.js} +1 -1
  30. package/web/assets/{chunk-POPQ4Y6H-pvDYQBBM.js → chunk-POPQ4Y6H-CDqgEp-q.js} +1 -1
  31. package/web/assets/{chunk-SVP7TREG-QuKLl3v0.js → chunk-SVP7TREG-B65ta9aQ.js} +1 -1
  32. package/web/assets/{chunk-XXDRQBXY-4Bt9wCBO.js → chunk-XXDRQBXY-DjTi6c9m.js} +1 -1
  33. package/web/assets/{chunk-ZLD2IHE6-C_RSrkqU.js → chunk-ZLD2IHE6-CbviXFvb.js} +1 -1
  34. package/web/assets/classDiagram-CYGNFDIV-BkidFauP.js +1 -0
  35. package/web/assets/classDiagram-v2-TLXNO2FR-BkidFauP.js +1 -0
  36. package/web/assets/{cose-bilkent-JH36ORCC-CciOYQb4.js → cose-bilkent-JH36ORCC-Cl9SO5zd.js} +1 -1
  37. package/web/assets/{cynefin-OW5HDTMX-k8-mUlq4.js → cynefin-OW5HDTMX-CAcz2tHt.js} +1 -1
  38. package/web/assets/{cynefinDiagram-5FMLGOSQ-D1qAHzq3.js → cynefinDiagram-5FMLGOSQ-BJHft4FO.js} +1 -1
  39. package/web/assets/{dagre-OS7QT2EB-BP1VuF_z.js → dagre-OS7QT2EB-DbdnY1uS.js} +1 -1
  40. package/web/assets/{diagram-S7CK7UJ4-crXkWleC.js → diagram-S7CK7UJ4-C07WD5Lc.js} +1 -1
  41. package/web/assets/{diagram-UQ7AKVKN-CtHAmCY-.js → diagram-UQ7AKVKN-D8VCjq9x.js} +1 -1
  42. package/web/assets/{diagram-VSXAHHWV-C-Fhuwy8.js → diagram-VSXAHHWV-D1XSfMnK.js} +1 -1
  43. package/web/assets/{diagram-VX7I27RA-DkRkNPuj.js → diagram-VX7I27RA-3GZe-yrf.js} +1 -1
  44. package/web/assets/{diagram-Z3DM3KII-DVERPCFS.js → diagram-Z3DM3KII-CI43WcIB.js} +1 -1
  45. package/web/assets/{ebnfDiagram-PWID7BFC-PrSifYGF.js → ebnfDiagram-PWID7BFC-CTzouade.js} +1 -1
  46. package/web/assets/{erDiagram-2YWLMYGG-D8-wfUYq.js → erDiagram-2YWLMYGG-CNcqsnqx.js} +1 -1
  47. package/web/assets/{flowDiagram-T62WH6J4-BS-_wkGe.js → flowDiagram-T62WH6J4-pomAqjgF.js} +1 -1
  48. package/web/assets/{ganttDiagram-EL5Y4UJY-D9PWmuWE.js → ganttDiagram-EL5Y4UJY-C1dNaxxj.js} +1 -1
  49. package/web/assets/{gitGraphDiagram-WWUBYQGX-DIP9p63c.js → gitGraphDiagram-WWUBYQGX-Ni8GECg1.js} +1 -1
  50. package/web/assets/{infoDiagram-FKFFQAWI-BzU6-acC.js → infoDiagram-FKFFQAWI-C0kvE7Zu.js} +1 -1
  51. package/web/assets/{ishikawaDiagram-5VMMS53U-CvYS2ayc.js → ishikawaDiagram-5VMMS53U-CxmP_irp.js} +1 -1
  52. package/web/assets/{journeyDiagram-EYS64GPL-Cr0qdK-D.js → journeyDiagram-EYS64GPL-60GkPgH5.js} +1 -1
  53. package/web/assets/{kanban-definition-UXKFOSKX-DhffGp7U.js → kanban-definition-UXKFOSKX-ByxcB0T_.js} +1 -1
  54. package/web/assets/{layout-b5jqOIam.js → layout-BD9ef90s.js} +1 -1
  55. package/web/assets/{linear-zLNMwGhI.js → linear-Dp9l0lBr.js} +1 -1
  56. package/web/assets/{mermaid.core-DB_TNiSG.js → mermaid.core-FKm3Akes.js} +6 -6
  57. package/web/assets/{mindmap-definition-THT77NOG-DkQz2hbh.js → mindmap-definition-THT77NOG-i7WJlWHV.js} +1 -1
  58. package/web/assets/{pegDiagram-XKGWAZYB-CB313CDF.js → pegDiagram-XKGWAZYB-DfI-LsJq.js} +1 -1
  59. package/web/assets/{pieDiagram-E7YTZNPT-yaDJVY4d.js → pieDiagram-E7YTZNPT-kcgWYBSb.js} +1 -1
  60. package/web/assets/{quadrantDiagram-AXDQQJYC-BkV8iwHS.js → quadrantDiagram-AXDQQJYC-DVyziZnc.js} +1 -1
  61. package/web/assets/{railroadDiagram-O6MQD6OU-T_JVDRpV.js → railroadDiagram-O6MQD6OU-CWgHq9nv.js} +1 -1
  62. package/web/assets/{requirementDiagram-IS5BZ75X-B68yTCqr.js → requirementDiagram-IS5BZ75X-B7fgi4lz.js} +1 -1
  63. package/web/assets/{sankeyDiagram-P5KCCOFB-Bx31O26O.js → sankeyDiagram-P5KCCOFB-D1-pD_qh.js} +1 -1
  64. package/web/assets/{sequenceDiagram-WJ2MYXX4-CsCoFTAg.js → sequenceDiagram-WJ2MYXX4-B6oeRmVj.js} +1 -1
  65. package/web/assets/{sizeCapture-INFHLROL-B3a49hkJ.js → sizeCapture-INFHLROL-BTpKGFt5.js} +1 -1
  66. package/web/assets/{stateDiagram-XQSTLZYL-DjCdHmx5.js → stateDiagram-XQSTLZYL-zXJRxE6h.js} +1 -1
  67. package/web/assets/stateDiagram-v2-IH3M54BS-CmwZ6tXj.js +1 -0
  68. package/web/assets/{swimlanes-V6O3JKXN-4u8S1I3e.js → swimlanes-V6O3JKXN-Dc7MXPb3.js} +1 -1
  69. package/web/assets/swimlanesDiagram-JKAHXJPX-BXoJEx2b.js +8 -0
  70. package/web/assets/{timeline-definition-24CTP7MA-r3GnOjJo.js → timeline-definition-24CTP7MA-DYhgbnjW.js} +1 -1
  71. package/web/assets/{vennDiagram-4TSXK5OY-59IlTU54.js → vennDiagram-4TSXK5OY-DRnme7za.js} +1 -1
  72. package/web/assets/{wardleyDiagram-VM6X3IG4-C3ALfLHE.js → wardleyDiagram-VM6X3IG4-wAoo4GsZ.js} +1 -1
  73. package/web/assets/{workspace-file-tree-C6GhNx3L.js → workspace-file-tree-DmAwvqtx.js} +1 -1
  74. package/web/assets/{xychartDiagram-S5SC5T6Z-CoYtWlkb.js → xychartDiagram-S5SC5T6Z-_Z_TOWBn.js} +1 -1
  75. package/web/index.html +2 -2
  76. package/web/assets/app-DVblmMbF.css +0 -32
  77. package/web/assets/channel-DwV664-P.js +0 -1
  78. package/web/assets/classDiagram-CYGNFDIV-BKhd9faI.js +0 -1
  79. package/web/assets/classDiagram-v2-TLXNO2FR-BKhd9faI.js +0 -1
  80. package/web/assets/stateDiagram-v2-IH3M54BS-Cf0tqY7x.js +0 -1
  81. package/web/assets/swimlanesDiagram-JKAHXJPX-COK5qx0u.js +0 -8
package/README.md CHANGED
@@ -9,7 +9,7 @@ npm install -g openwork-server
9
9
  openwork-server --workspace /path/to/workspace --approval auto
10
10
  ```
11
11
 
12
- The npm package runs on Node.js 22.13 or newer on macOS, Linux, and Windows (arm64 or x64); Bun is not required at runtime.
12
+ The npm package requires stable Node.js 24.x, version 24.22.1 or newer within that major, on macOS, Linux, and Windows (arm64 or x64); Bun is not required at runtime. Older Node releases and other majors (including 25+) are rejected by the runtime gate. This security baseline also replaces the previous Node.js 22.13 minimum.
13
13
 
14
14
  ### Self-hosted web UI
15
15
 
@@ -34,8 +34,14 @@ persisted in `~/.openwork/openwork-server/web-tokens.json` so they survive
34
34
  restarts; `--token`/`--host-token` or `OPENWORK_TOKEN`/`OPENWORK_HOST_TOKEN`
35
35
  override them.
36
36
 
37
+ In `web` mode the UI's pages never carry CORS headers, and the server only
38
+ answers requests addressed to `localhost`, an IP address or the `--host` name.
39
+ Add other names with `--allowed-hosts` (or `OPENWORK_ALLOWED_HOSTS`,
40
+ comma-separated; `*` allows any).
41
+
37
42
  To reach it from other machines, keep the server on `127.0.0.1` and put a TLS
38
- proxy in front (e.g. `tailscale serve --bg --https=8787 http://127.0.0.1:8787`).
43
+ proxy in front (e.g. `tailscale serve --bg --https=8787 http://127.0.0.1:8787`),
44
+ then allow the proxy's name (e.g. `--allowed-hosts my-machine.tailnet.ts.net`).
39
45
  Browsers require a secure context for the UI on any non-localhost origin.
40
46
  Set `OPENWORK_OPENCODE_BIN` to use your own OpenCode install instead of the
41
47
  managed download; `/health` reports both the pinned `opencodeVersion` and the
@@ -175,6 +181,7 @@ Defaults to `~/.config/openwork/server.json` (override with `OPENWORK_SERVER_CON
175
181
  - `OPENWORK_APPROVAL_TIMEOUT_MS`
176
182
  - `OPENWORK_WORKSPACES` (JSON array or comma-separated list of paths)
177
183
  - `OPENWORK_CORS_ORIGINS` (comma-separated list or `*`)
184
+ - `OPENWORK_ALLOWED_HOSTS` (comma-separated host names or `*`; `web` mode always checks the Host)
178
185
  - `OPENWORK_OPENCODE_BASE_URL`
179
186
  - `OPENWORK_OPENCODE_DIRECTORY`
180
187
  - `OPENWORK_OPENCODE_USERNAME`
@@ -29,7 +29,7 @@ const nodeBundle = fromPackage("./dist/openwork-server.mjs");
29
29
  if (existsSync(nodeBundle)) {
30
30
  if (!nodeVersionSupported(process.versions.node)) {
31
31
  console.error(
32
- `openwork-server needs Node.js ${MIN_NODE_VERSION} or newer (found ${process.versions.node}). Install a current Node.js LTS and try again.`,
32
+ `openwork-server needs Node.js 24.x, version ${MIN_NODE_VERSION} or newer within that major (found ${process.versions.node}). Install a supported Node.js 24 release and try again.`,
33
33
  );
34
34
  process.exit(1);
35
35
  }
package/bin/platform.mjs CHANGED
@@ -16,13 +16,13 @@ export function serverBinaryName(platform, arch) {
16
16
  return `openwork-server-${target.target}${platform === "win32" ? ".exe" : ""}`;
17
17
  }
18
18
 
19
- // node:sqlite (the server's runtime database) is available without a flag
20
- // from Node 22.13 and 23.4.
21
- export const MIN_NODE_VERSION = "22.13.0";
19
+ // Security baseline: only patched releases on the supported Node 24 line.
20
+ // New majors must be reviewed explicitly, not accepted by a lower-bound check.
21
+ export const MIN_NODE_VERSION = "24.22.1";
22
22
 
23
23
  export function nodeVersionSupported(version) {
24
- const [major, minor] = version.replace(/^v/, "").split(".").map(Number);
25
- if (major === 22) return minor >= 13;
26
- if (major === 23) return minor >= 4;
27
- return major > 23;
24
+ const match = /^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.exec(version);
25
+ if (!match) return false;
26
+ const [, major, minor, patch] = match.map(Number);
27
+ return major === 24 && (minor > 22 || (minor === 22 && patch >= 1));
28
28
  }
@@ -1,13 +1,15 @@
1
1
  import { z } from "zod";
2
2
  // The host performs these actions for its signed-in caller, as with drive_upload_file. Agents use these tools, which ask before writes.
3
3
  export const DRIVE_FILE_ACTIONS = ["drive_pull_file", "drive_file_status", "drive_push_file"];
4
- const connectionId = z.string().regex(/^(google-workspace|emc_[A-Za-z0-9]+)$/).optional()
5
- .describe("Selected Google Workspace connection; omit for the default. Unavailable selections never fall back to another account.");
4
+ const connectionId = z.string().regex(/^(google-workspace|microsoft-365|emc_[A-Za-z0-9]+)$/).optional()
5
+ .describe("Selected Google Workspace or Microsoft 365 connection; omit for the default. Unavailable selections never fall back to another account.");
6
6
  const workspacePath = z.string().min(1).max(4096);
7
7
  export const drivePullFileArgs = z.object({
8
- fileId: z.string().min(1).max(2048).describe("Drive file ID or link."),
8
+ fileId: z.string().min(1).max(2048).describe("Google Drive file ID or link, or a OneDrive, SharePoint or Teams file link or item id."),
9
+ driveId: z.string().min(1).max(512).optional().describe("Microsoft 365 only: the driveId that came with the item id from a search or folder listing."),
10
+ provider: z.enum(["google-workspace", "microsoft-365"]).optional().describe("Only needed for a Microsoft item id without driveId; links and driveId identify Microsoft 365 files."),
9
11
  path: workspacePath.optional().describe("Workspace path to save to. Defaults to drive/<file name>."),
10
- format: z.string().regex(/^[a-z]{2,5}$/).optional().describe("Docs, Sheets, Slides and Drawings only: xlsx, csv (first tab only), docx, md, txt, pptx, pdf, png or svg."),
12
+ format: z.string().regex(/^[a-z]{2,5}$/).optional().describe("Docs, Sheets, Slides and Drawings: xlsx, csv (first tab only), docx, md, txt, pptx, pdf, png or svg. OneDrive and SharePoint: pdf converts an Office file."),
11
13
  connectionId,
12
14
  overwrite: z.boolean().optional().describe("Replace a local copy even if it has edits not in Drive. Asks the person first."),
13
15
  }).strict();
@@ -16,7 +18,7 @@ export const driveFileStatusArgs = z.object({
16
18
  }).strict();
17
19
  export const drivePushFileArgs = z.object({
18
20
  path: workspacePath.describe("Workspace path of a pulled file."),
19
- mode: z.enum(["replace", "copy"]).optional().describe("replace (default for stored files) updates the same Drive file; copy saves a new file next to it."),
21
+ mode: z.enum(["replace", "copy"]).optional().describe("replace (default for stored files) updates the same file; copy saves a new file next to it."),
20
22
  name: z.string().min(1).max(255).optional().describe("Name for a copy."),
21
23
  }).strict();
22
24
  function isRecord(value) {
@@ -33,7 +35,7 @@ async function askPermission(context, permission, patterns, metadata) {
33
35
  export function createDriveFileTools(call) {
34
36
  return {
35
37
  drive_pull_file: {
36
- description: "Save a Google Drive file into the workspace and return its path and revision, never its content. Use it to analyze or edit Office, PDF, CSV or any large file with shell tools. Docs, Sheets and Slides arrive as docx, xlsx and pptx unless format says otherwise.",
38
+ description: "Save a Google Drive, OneDrive or SharePoint file into the workspace and return its path and revision, never its content. Use it to analyze or edit Office, PDF, CSV or any large file with shell tools. Docs, Sheets and Slides arrive as docx, xlsx and pptx unless format says otherwise.",
37
39
  args: drivePullFileArgs.shape,
38
40
  async execute(rawArgs, context) {
39
41
  const args = drivePullFileArgs.parse(rawArgs ?? {});
@@ -43,14 +45,14 @@ export function createDriveFileTools(call) {
43
45
  },
44
46
  },
45
47
  drive_file_status: {
46
- description: "For files pulled with drive_pull_file, report whether the local copy has edits not saved to Drive and whether the file changed in Drive since the pull.",
48
+ description: "For files pulled with drive_pull_file, report whether the local copy has edits not saved back and whether the file changed in Drive, OneDrive or SharePoint since the pull.",
47
49
  args: driveFileStatusArgs.shape,
48
50
  async execute(rawArgs, context) {
49
51
  return JSON.stringify(await call("drive_file_status", driveFileStatusArgs.parse(rawArgs ?? {}), context), null, 2);
50
52
  },
51
53
  },
52
54
  drive_push_file: {
53
- description: "Save a pulled file back to Google Drive when the person asks. Asks the person first. Stored files are replaced in place as a new Drive version; Docs, Sheets and Slides are saved as a new Google file next to the original. Returns drive_conflict, and changes nothing, when someone changed the file in Drive since the pull: pull it again and reapply the edits, or save a copy.",
55
+ description: "Save a pulled file back to where it came from when the person asks. Asks the person first. Stored files, including Office files in OneDrive and SharePoint, are replaced in place as a new version; Google Docs, Sheets and Slides and PDF conversions are saved as a new file next to the original. Returns drive_conflict, and changes nothing, when someone changed the file since the pull: pull it again and reapply the edits, or save a copy.",
54
56
  args: drivePushFileArgs.shape,
55
57
  async execute(rawArgs, context) {
56
58
  const args = drivePushFileArgs.parse(rawArgs ?? {});
@@ -19,6 +19,9 @@ const MAX_SKILLS = 150;
19
19
  const NATIVE_BUDGET_CHARS = 64_000;
20
20
  const NATIVE_DESCRIPTION_CHARS = 1_024;
21
21
  const MAX_MENTIONED_SKILLS = 3;
22
+ // Bodies kept for the skills messages used most recently, so using one again
23
+ // needs no read before the model runs. Every other skill loads on use.
24
+ const MAX_WARM_SKILLS = 8;
22
25
  const skillId = (skill) => `${CLOUD_SKILL_ID_PREFIX}${skill.name}`;
23
26
  // Not SKILL.md: the native skill tool must not scan a fake directory. The engine
24
27
  // requires path and disables this whole plugin, OpenWork's context tools
@@ -60,10 +63,10 @@ function budgeted(skills, budget, descriptionChars, content) {
60
63
  return result;
61
64
  }
62
65
  /**
63
- * Each organization skill is a native skill
64
- * with its full description and its authorized SKILL.md, verified against the
65
- * digest Den published. A skill whose body could not be read or verified keeps
66
- * the discovery note, so it still works through get_skill.
66
+ * Each organization skill is a native skill with its full description. A
67
+ * skill whose authorized SKILL.md has been read for a message, verified against
68
+ * the digest Den published, carries it; every other skill keeps the discovery
69
+ * note, so it still works through get_skill.
67
70
  */
68
71
  export function nativeSkills(catalog, contents) {
69
72
  return budgeted(catalog.skills, NATIVE_BUDGET_CHARS, NATIVE_DESCRIPTION_CHARS, skill => {
@@ -113,15 +116,19 @@ export function mentionedSkillIds(text, skills) {
113
116
  }
114
117
  /** Setup starts background discovery without making Cloud a dependency of
115
118
  * chatting or local skill discovery. Only authorized catalog entries and
116
- * digest-verified bodies are added to the native registry. */
119
+ * digest-verified bodies are added to the native registry. Discovery reads
120
+ * metadata only; a body is read when a message names or attaches its skill. */
117
121
  export async function registerCloudSkillDiscovery(context) {
118
122
  let skills = [];
123
+ let catalog = { skills: [] };
119
124
  let fingerprint = "[]";
120
125
  let closed = false;
121
126
  let refreshing = false;
122
- // Verified SKILL.md bodies by digest, in memory only. A body is re-read only
123
- // when Den publishes a new digest, and dropped as soon as Den stops listing it.
127
+ // Verified SKILL.md bodies by digest, in memory only, for the warm skills.
128
+ // A body is re-read only when Den publishes a new digest for one of them.
124
129
  const contents = new Map();
130
+ // Skill IDs messages used, most recent last.
131
+ const warm = [];
125
132
  const controller = new AbortController();
126
133
  let registration;
127
134
  let promptHook;
@@ -134,44 +141,34 @@ export async function registerCloudSkillDiscovery(context) {
134
141
  });
135
142
  return response.ok ? await response.json() : null;
136
143
  };
137
- const loadContents = async (catalog) => {
138
- const wanted = new Set(catalog.skills.flatMap(skill => skill.digest && !skill.setup ? [skill.digest] : []));
139
- for (const digest of contents.keys())
140
- if (!wanted.has(digest))
141
- contents.delete(digest);
142
- for (const skill of catalog.skills) {
143
- if (closed || !skill.digest || !skill.url || skill.setup || contents.has(skill.digest))
144
- continue;
145
- const result = z.object({ content: z.string() }).safeParse(await call("openwork_skill_read", { url: skill.url }).catch(() => null));
144
+ /** Read listed skills' bodies in one request and keep those matching Den's digest. */
145
+ const readBodies = async (entries, use) => {
146
+ const loadable = entries.filter(skill => skill.url && skill.digest && !skill.setup);
147
+ if (loadable.length === 0)
148
+ return;
149
+ const result = z.object({ contents: z.record(z.string(), z.string()) })
150
+ .safeParse(await call("openwork_skill_read", { urls: loadable.map(skill => skill.url), ...(use ? { use } : {}) }).catch(() => null));
151
+ if (!result.success)
152
+ return;
153
+ for (const skill of loadable) {
154
+ const body = result.data.contents[skill.url];
146
155
  // Only a body that matches Den's published digest is used.
147
- if (result.success && sha256Digest(result.data.content) === skill.digest)
148
- contents.set(skill.digest, result.data.content);
156
+ if (body !== undefined && sha256Digest(body) === skill.digest)
157
+ contents.set(skill.digest, body);
149
158
  }
150
159
  };
151
- const ensurePromptHook = async () => {
152
- if (promptHook || !context.session)
153
- return;
154
- promptHook = await context.session.hook("prompt", event => {
155
- if (skills.length === 0)
156
- return;
157
- const attached = new Set((event.prompt.skills ?? []).map(skill => skill.id));
158
- const mentioned = mentionedSkillIds(event.prompt.text, skills).filter(id => !attached.has(id));
159
- if (mentioned.length === 0)
160
- return;
161
- event.prompt.skills = [...(event.prompt.skills ?? []), ...mentioned.map(id => ({ id }))];
162
- });
160
+ const warmEntries = () => {
161
+ const listed = new Set(nativeSkills(catalog, contents).map(skill => skill.id));
162
+ return catalog.skills.filter(skill => warm.includes(skillId(skill)) && listed.has(skillId(skill)));
163
163
  };
164
- const refresh = async () => {
165
- if (closed || refreshing)
166
- return;
167
- refreshing = true;
168
- try {
169
- const value = await call("openwork_skills", {});
170
- if (value === null)
171
- return;
172
- const catalog = catalogSchema.parse(value);
173
- await loadContents(catalog);
174
- await ensurePromptHook();
164
+ // Registry updates run one at a time, from refreshes and prompts alike.
165
+ let publishing = Promise.resolve();
166
+ const publish = () => {
167
+ publishing = publishing.catch(() => { }).then(async () => {
168
+ const wanted = new Set(warmEntries().flatMap(skill => skill.digest && !skill.setup ? [skill.digest] : []));
169
+ for (const digest of contents.keys())
170
+ if (!wanted.has(digest))
171
+ contents.delete(digest);
175
172
  const next = nativeSkills(catalog, contents);
176
173
  const key = JSON.stringify(next);
177
174
  if (closed || key === fingerprint)
@@ -198,6 +195,54 @@ export async function registerCloudSkillDiscovery(context) {
198
195
  await context.skill.reload();
199
196
  }
200
197
  fingerprint = key;
198
+ });
199
+ return publishing;
200
+ };
201
+ const ensurePromptHook = async () => {
202
+ if (promptHook || !context.session)
203
+ return;
204
+ promptHook = await context.session.hook("prompt", async (event) => {
205
+ if (skills.length === 0)
206
+ return;
207
+ const attached = new Set((event.prompt.skills ?? []).map(skill => skill.id));
208
+ const mentioned = mentionedSkillIds(event.prompt.text, skills).filter(id => !attached.has(id));
209
+ if (mentioned.length)
210
+ event.prompt.skills = [...(event.prompt.skills ?? []), ...mentioned.map(id => ({ id }))];
211
+ // Only registered skills: an unlisted ID never costs a read.
212
+ const registered = new Set(skills.map(skill => skill.id));
213
+ const used = new Set([...attached, ...mentioned].filter(id => registered.has(id)));
214
+ const entries = catalog.skills.filter(skill => used.has(skillId(skill)));
215
+ if (entries.length === 0)
216
+ return;
217
+ try {
218
+ // Freshly authorized for this message, and counted by Den as a use.
219
+ // A body that cannot be read leaves the discovery note: get_skill still works.
220
+ await readBodies(entries, true);
221
+ for (const skill of entries) {
222
+ const id = skillId(skill);
223
+ warm.splice(0, warm.length, ...warm.filter(entry => entry !== id), id);
224
+ }
225
+ warm.splice(0, Math.max(0, warm.length - MAX_WARM_SKILLS));
226
+ await publish();
227
+ }
228
+ catch {
229
+ // A message is never held back by Cloud skill loading.
230
+ }
231
+ });
232
+ };
233
+ const refresh = async () => {
234
+ if (closed || refreshing)
235
+ return;
236
+ refreshing = true;
237
+ try {
238
+ const value = await call("openwork_skills", {});
239
+ if (value === null)
240
+ return;
241
+ catalog = catalogSchema.parse(value);
242
+ // Only warm skills whose digest changed are read again.
243
+ await readBodies(warmEntries().filter(skill => skill.digest && !contents.has(skill.digest)), false);
244
+ await ensurePromptHook();
245
+ await publish();
201
246
  }
202
247
  catch {
203
248
  // Cloud readiness is never a prerequisite to chatting. Discovery entries
@@ -216,6 +261,7 @@ export async function registerCloudSkillDiscovery(context) {
216
261
  controller.abort();
217
262
  skills = [];
218
263
  contents.clear();
264
+ await publishing.catch(() => { });
219
265
  await promptHook?.dispose();
220
266
  await registration?.dispose();
221
267
  };
@@ -13813,6 +13813,7 @@ var MAX_SKILLS = 150;
13813
13813
  var NATIVE_BUDGET_CHARS = 64000;
13814
13814
  var NATIVE_DESCRIPTION_CHARS = 1024;
13815
13815
  var MAX_MENTIONED_SKILLS = 3;
13816
+ var MAX_WARM_SKILLS = 8;
13816
13817
  var skillId = (skill) => `${CLOUD_SKILL_ID_PREFIX}${skill.name}`;
13817
13818
  var skillPath = (skill) => `/openwork/remote-skills/${skill.name}.md`;
13818
13819
  function discoveryContent(skill) {
@@ -13890,10 +13891,12 @@ function mentionedSkillIds(text2, skills) {
13890
13891
  }
13891
13892
  async function registerCloudSkillDiscovery(context) {
13892
13893
  let skills = [];
13894
+ let catalog = { skills: [] };
13893
13895
  let fingerprint = "[]";
13894
13896
  let closed = false;
13895
13897
  let refreshing = false;
13896
13898
  const contents = new Map;
13899
+ const warm = [];
13897
13900
  const controller = new AbortController;
13898
13901
  let registration;
13899
13902
  let promptHook;
@@ -13907,43 +13910,30 @@ async function registerCloudSkillDiscovery(context) {
13907
13910
  });
13908
13911
  return response.ok ? await response.json() : null;
13909
13912
  };
13910
- const loadContents = async (catalog) => {
13911
- const wanted = new Set(catalog.skills.flatMap((skill) => skill.digest && !skill.setup ? [skill.digest] : []));
13912
- for (const digest of contents.keys())
13913
- if (!wanted.has(digest))
13914
- contents.delete(digest);
13915
- for (const skill of catalog.skills) {
13916
- if (closed || !skill.digest || !skill.url || skill.setup || contents.has(skill.digest))
13917
- continue;
13918
- const result = exports_external.object({ content: exports_external.string() }).safeParse(await call("openwork_skill_read", { url: skill.url }).catch(() => null));
13919
- if (result.success && sha256Digest(result.data.content) === skill.digest)
13920
- contents.set(skill.digest, result.data.content);
13921
- }
13922
- };
13923
- const ensurePromptHook = async () => {
13924
- if (promptHook || !context.session)
13913
+ const readBodies = async (entries, use) => {
13914
+ const loadable = entries.filter((skill) => skill.url && skill.digest && !skill.setup);
13915
+ if (loadable.length === 0)
13925
13916
  return;
13926
- promptHook = await context.session.hook("prompt", (event) => {
13927
- if (skills.length === 0)
13928
- return;
13929
- const attached = new Set((event.prompt.skills ?? []).map((skill) => skill.id));
13930
- const mentioned = mentionedSkillIds(event.prompt.text, skills).filter((id) => !attached.has(id));
13931
- if (mentioned.length === 0)
13932
- return;
13933
- event.prompt.skills = [...event.prompt.skills ?? [], ...mentioned.map((id) => ({ id }))];
13934
- });
13935
- };
13936
- const refresh = async () => {
13937
- if (closed || refreshing)
13917
+ const result = exports_external.object({ contents: exports_external.record(exports_external.string(), exports_external.string()) }).safeParse(await call("openwork_skill_read", { urls: loadable.map((skill) => skill.url), ...use ? { use } : {} }).catch(() => null));
13918
+ if (!result.success)
13938
13919
  return;
13939
- refreshing = true;
13940
- try {
13941
- const value = await call("openwork_skills", {});
13942
- if (value === null)
13943
- return;
13944
- const catalog = catalogSchema.parse(value);
13945
- await loadContents(catalog);
13946
- await ensurePromptHook();
13920
+ for (const skill of loadable) {
13921
+ const body = result.data.contents[skill.url];
13922
+ if (body !== undefined && sha256Digest(body) === skill.digest)
13923
+ contents.set(skill.digest, body);
13924
+ }
13925
+ };
13926
+ const warmEntries = () => {
13927
+ const listed = new Set(nativeSkills(catalog, contents).map((skill) => skill.id));
13928
+ return catalog.skills.filter((skill) => warm.includes(skillId(skill)) && listed.has(skillId(skill)));
13929
+ };
13930
+ let publishing = Promise.resolve();
13931
+ const publish = () => {
13932
+ publishing = publishing.catch(() => {}).then(async () => {
13933
+ const wanted = new Set(warmEntries().flatMap((skill) => skill.digest && !skill.setup ? [skill.digest] : []));
13934
+ for (const digest of contents.keys())
13935
+ if (!wanted.has(digest))
13936
+ contents.delete(digest);
13947
13937
  const next = nativeSkills(catalog, contents);
13948
13938
  const key = JSON.stringify(next);
13949
13939
  if (closed || key === fingerprint)
@@ -13968,6 +13958,47 @@ async function registerCloudSkillDiscovery(context) {
13968
13958
  await context.skill.reload();
13969
13959
  }
13970
13960
  fingerprint = key;
13961
+ });
13962
+ return publishing;
13963
+ };
13964
+ const ensurePromptHook = async () => {
13965
+ if (promptHook || !context.session)
13966
+ return;
13967
+ promptHook = await context.session.hook("prompt", async (event) => {
13968
+ if (skills.length === 0)
13969
+ return;
13970
+ const attached = new Set((event.prompt.skills ?? []).map((skill) => skill.id));
13971
+ const mentioned = mentionedSkillIds(event.prompt.text, skills).filter((id) => !attached.has(id));
13972
+ if (mentioned.length)
13973
+ event.prompt.skills = [...event.prompt.skills ?? [], ...mentioned.map((id) => ({ id }))];
13974
+ const registered = new Set(skills.map((skill) => skill.id));
13975
+ const used = new Set([...attached, ...mentioned].filter((id) => registered.has(id)));
13976
+ const entries = catalog.skills.filter((skill) => used.has(skillId(skill)));
13977
+ if (entries.length === 0)
13978
+ return;
13979
+ try {
13980
+ await readBodies(entries, true);
13981
+ for (const skill of entries) {
13982
+ const id = skillId(skill);
13983
+ warm.splice(0, warm.length, ...warm.filter((entry) => entry !== id), id);
13984
+ }
13985
+ warm.splice(0, Math.max(0, warm.length - MAX_WARM_SKILLS));
13986
+ await publish();
13987
+ } catch {}
13988
+ });
13989
+ };
13990
+ const refresh = async () => {
13991
+ if (closed || refreshing)
13992
+ return;
13993
+ refreshing = true;
13994
+ try {
13995
+ const value = await call("openwork_skills", {});
13996
+ if (value === null)
13997
+ return;
13998
+ catalog = catalogSchema.parse(value);
13999
+ await readBodies(warmEntries().filter((skill) => skill.digest && !contents.has(skill.digest)), false);
14000
+ await ensurePromptHook();
14001
+ await publish();
13971
14002
  } catch {} finally {
13972
14003
  refreshing = false;
13973
14004
  }
@@ -13981,6 +14012,7 @@ async function registerCloudSkillDiscovery(context) {
13981
14012
  controller.abort();
13982
14013
  skills = [];
13983
14014
  contents.clear();
14015
+ await publishing.catch(() => {});
13984
14016
  await promptHook?.dispose();
13985
14017
  await registration?.dispose();
13986
14018
  };
@@ -13873,12 +13873,14 @@ async function uiBridgeRequest(path, options = {}) {
13873
13873
 
13874
13874
  // src/opencode-plugins/drive-file-tools.ts
13875
13875
  var DRIVE_FILE_ACTIONS = ["drive_pull_file", "drive_file_status", "drive_push_file"];
13876
- var connectionId = exports_external.string().regex(/^(google-workspace|emc_[A-Za-z0-9]+)$/).optional().describe("Selected Google Workspace connection; omit for the default. Unavailable selections never fall back to another account.");
13876
+ var connectionId = exports_external.string().regex(/^(google-workspace|microsoft-365|emc_[A-Za-z0-9]+)$/).optional().describe("Selected Google Workspace or Microsoft 365 connection; omit for the default. Unavailable selections never fall back to another account.");
13877
13877
  var workspacePath = exports_external.string().min(1).max(4096);
13878
13878
  var drivePullFileArgs = exports_external.object({
13879
- fileId: exports_external.string().min(1).max(2048).describe("Drive file ID or link."),
13879
+ fileId: exports_external.string().min(1).max(2048).describe("Google Drive file ID or link, or a OneDrive, SharePoint or Teams file link or item id."),
13880
+ driveId: exports_external.string().min(1).max(512).optional().describe("Microsoft 365 only: the driveId that came with the item id from a search or folder listing."),
13881
+ provider: exports_external.enum(["google-workspace", "microsoft-365"]).optional().describe("Only needed for a Microsoft item id without driveId; links and driveId identify Microsoft 365 files."),
13880
13882
  path: workspacePath.optional().describe("Workspace path to save to. Defaults to drive/<file name>."),
13881
- format: exports_external.string().regex(/^[a-z]{2,5}$/).optional().describe("Docs, Sheets, Slides and Drawings only: xlsx, csv (first tab only), docx, md, txt, pptx, pdf, png or svg."),
13883
+ format: exports_external.string().regex(/^[a-z]{2,5}$/).optional().describe("Docs, Sheets, Slides and Drawings: xlsx, csv (first tab only), docx, md, txt, pptx, pdf, png or svg. OneDrive and SharePoint: pdf converts an Office file."),
13882
13884
  connectionId,
13883
13885
  overwrite: exports_external.boolean().optional().describe("Replace a local copy even if it has edits not in Drive. Asks the person first.")
13884
13886
  }).strict();
@@ -13887,7 +13889,7 @@ var driveFileStatusArgs = exports_external.object({
13887
13889
  }).strict();
13888
13890
  var drivePushFileArgs = exports_external.object({
13889
13891
  path: workspacePath.describe("Workspace path of a pulled file."),
13890
- mode: exports_external.enum(["replace", "copy"]).optional().describe("replace (default for stored files) updates the same Drive file; copy saves a new file next to it."),
13892
+ mode: exports_external.enum(["replace", "copy"]).optional().describe("replace (default for stored files) updates the same file; copy saves a new file next to it."),
13891
13893
  name: exports_external.string().min(1).max(255).optional().describe("Name for a copy.")
13892
13894
  }).strict();
13893
13895
  function isRecord2(value) {
@@ -13902,7 +13904,7 @@ async function askPermission(context, permission, patterns, metadata) {
13902
13904
  function createDriveFileTools(call) {
13903
13905
  return {
13904
13906
  drive_pull_file: {
13905
- description: "Save a Google Drive file into the workspace and return its path and revision, never its content. Use it to analyze or edit Office, PDF, CSV or any large file with shell tools. Docs, Sheets and Slides arrive as docx, xlsx and pptx unless format says otherwise.",
13907
+ description: "Save a Google Drive, OneDrive or SharePoint file into the workspace and return its path and revision, never its content. Use it to analyze or edit Office, PDF, CSV or any large file with shell tools. Docs, Sheets and Slides arrive as docx, xlsx and pptx unless format says otherwise.",
13906
13908
  args: drivePullFileArgs.shape,
13907
13909
  async execute(rawArgs, context) {
13908
13910
  const args = drivePullFileArgs.parse(rawArgs ?? {});
@@ -13912,14 +13914,14 @@ function createDriveFileTools(call) {
13912
13914
  }
13913
13915
  },
13914
13916
  drive_file_status: {
13915
- description: "For files pulled with drive_pull_file, report whether the local copy has edits not saved to Drive and whether the file changed in Drive since the pull.",
13917
+ description: "For files pulled with drive_pull_file, report whether the local copy has edits not saved back and whether the file changed in Drive, OneDrive or SharePoint since the pull.",
13916
13918
  args: driveFileStatusArgs.shape,
13917
13919
  async execute(rawArgs, context) {
13918
13920
  return JSON.stringify(await call("drive_file_status", driveFileStatusArgs.parse(rawArgs ?? {}), context), null, 2);
13919
13921
  }
13920
13922
  },
13921
13923
  drive_push_file: {
13922
- description: "Save a pulled file back to Google Drive when the person asks. Asks the person first. Stored files are replaced in place as a new Drive version; Docs, Sheets and Slides are saved as a new Google file next to the original. Returns drive_conflict, and changes nothing, when someone changed the file in Drive since the pull: pull it again and reapply the edits, or save a copy.",
13924
+ description: "Save a pulled file back to where it came from when the person asks. Asks the person first. Stored files, including Office files in OneDrive and SharePoint, are replaced in place as a new version; Google Docs, Sheets and Slides and PDF conversions are saved as a new file next to the original. Returns drive_conflict, and changes nothing, when someone changed the file since the pull: pull it again and reapply the edits, or save a copy.",
13923
13925
  args: drivePushFileArgs.shape,
13924
13926
  async execute(rawArgs, context) {
13925
13927
  const args = drivePushFileArgs.parse(rawArgs ?? {});