@moondeck/station 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (325) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +149 -2
  3. package/bin/uai-host.mjs +130 -0
  4. package/db/migrations/0000_host_tasks.sql +12 -0
  5. package/db/migrations/0001_host_ui.sql +11 -0
  6. package/db/migrations/0002_host_github_tokens.sql +8 -0
  7. package/db/migrations/0003_host_ssh_keys.sql +8 -0
  8. package/db/migrations/0004_host_owner_name.sql +1 -0
  9. package/db/migrations/0005_host_env_vars.sql +5 -0
  10. package/db/migrations/0006_host_project_env.sql +9 -0
  11. package/db/migrations/0007_host_github_token_kind.sql +7 -0
  12. package/db/migrations/0008_host_mcp_connections.sql +18 -0
  13. package/db/migrations/0009_host_agent_sessions.sql +15 -0
  14. package/db/migrations/0010_host_engine_accounts.sql +13 -0
  15. package/db/migrations/0011_codex_thread_id.sql +5 -0
  16. package/db/migrations/0012_agent_session_attach_key.sql +4 -0
  17. package/db/migrations/0013_github_credential_generations.sql +11 -0
  18. package/db/migrations/0014_host_inventory_event_index.sql +1 -0
  19. package/db/migrations/0015_host_settings.sql +9 -0
  20. package/db/migrations/0016_task_environment.sql +2 -0
  21. package/db/migrations/0017_engine_account_scope.sql +7 -0
  22. package/db/migrations/0018_engine_account_position.sql +3 -0
  23. package/db/migrations/0019_engine_account_store_ref.sql +3 -0
  24. package/db/migrations/0020_user_credential_store_ref.sql +6 -0
  25. package/db/migrations/0021_project_env_store_ref.sql +3 -0
  26. package/db/migrations/0022_task_project_ids.sql +5 -0
  27. package/db/migrations/0023_project_env_org.sql +4 -0
  28. package/db/migrations/0024_task_env_mode.sql +9 -0
  29. package/db/migrations/0025_project_env_authority.sql +147 -0
  30. package/db/migrations/0026_project_env_replica.sql +150 -0
  31. package/db/migrations/0027_cloud_credential_foundation.sql +51 -0
  32. package/db/migrations/0028_leased_engine_runtimes.sql +11 -0
  33. package/db/migrations/0029_task_shared_files_mode.sql +5 -0
  34. package/db/migrations/0030_credential_cleanup_restops.sql +12 -0
  35. package/db/migrations/meta/_journal.json +223 -0
  36. package/db/schema.ts +850 -0
  37. package/images/standard/Dockerfile +347 -0
  38. package/images/standard/README.md +167 -0
  39. package/images/standard/container/code-server-settings.json +63 -0
  40. package/images/standard/container/corepack-version +1 -0
  41. package/images/standard/container/uai-init +704 -0
  42. package/images/standard/container/uai-materialize-runtimes +1527 -0
  43. package/images/standard/container/uai-task-forwarder.mjs +303 -0
  44. package/images/standard/tool-versions +2 -0
  45. package/lib/__fixtures__/skill-index/SKILL.md +10 -0
  46. package/lib/__fixtures__/skill-index/skills/accessibility-checklist.md +7 -0
  47. package/lib/__fixtures__/skill-index/skills/brand-voice.md +9 -0
  48. package/lib/__fixtures__/skill-index/skills/design-tokens.md +9 -0
  49. package/lib/agent-cli.ts +1507 -0
  50. package/lib/agent-errors.ts +22 -0
  51. package/lib/agent.ts +672 -0
  52. package/lib/agents/attach-compatibility.ts +51 -0
  53. package/lib/agents/claude.ts +918 -0
  54. package/lib/agents/codex-token-usage.fixture.json +130 -0
  55. package/lib/agents/codex.ts +1392 -0
  56. package/lib/agents/cursor-period-usage.ts +253 -0
  57. package/lib/agents/cursor.ts +562 -0
  58. package/lib/agents/dispatch.ts +78 -0
  59. package/lib/agents/durable-proc.ts +334 -0
  60. package/lib/agents/factory.ts +57 -0
  61. package/lib/agents/grok.ts +381 -0
  62. package/lib/agents/kimi.ts +329 -0
  63. package/lib/agents/machine-durable.ts +604 -0
  64. package/lib/agents/mock.ts +133 -0
  65. package/lib/agents/mode.ts +64 -0
  66. package/lib/agents/opencode.ts +508 -0
  67. package/lib/agents/output-source.ts +3 -0
  68. package/lib/agents/proc.ts +221 -0
  69. package/lib/agents/rate-limit.ts +337 -0
  70. package/lib/agents/registry.ts +182 -0
  71. package/lib/agents/session-state.ts +197 -0
  72. package/lib/agents/skill-index-preamble.fixture.ts +26 -0
  73. package/lib/agents/transport.ts +741 -0
  74. package/lib/agents/types.ts +254 -0
  75. package/lib/agents/uai.ts +106 -0
  76. package/lib/agents/usage.ts +44 -0
  77. package/lib/apple-one-off.ts +277 -0
  78. package/lib/apple-runtime-recycle.ts +237 -0
  79. package/lib/apple-uninstall-teardown.ts +224 -0
  80. package/lib/assignment-evidence.ts +94 -0
  81. package/lib/attachments.ts +48 -0
  82. package/lib/browser-testing.ts +1537 -0
  83. package/lib/capture-flow.ts +332 -0
  84. package/lib/cloud-credential-cleanup-adapter.ts +123 -0
  85. package/lib/cloud-credential-host.ts +512 -0
  86. package/lib/cloud-credential-leases.ts +2587 -0
  87. package/lib/cloud-credential-runtime.ts +1357 -0
  88. package/lib/cloud-state.ts +56 -0
  89. package/lib/codex-auth.ts +456 -0
  90. package/lib/command-db.ts +310 -0
  91. package/lib/compose-network-recovery.ts +283 -0
  92. package/lib/connection-secret-redaction.ts +25 -0
  93. package/lib/container-runtime.ts +1453 -0
  94. package/lib/credential-cleanup-inventory.ts +490 -0
  95. package/lib/credential-consumers/engine-anchor.ts +1880 -0
  96. package/lib/credential-consumers/engines.ts +797 -0
  97. package/lib/credential-consumers/files.ts +934 -0
  98. package/lib/credential-consumers/github.ts +702 -0
  99. package/lib/credential-consumers/mcp.ts +389 -0
  100. package/lib/credential-consumers/task-files.ts +127 -0
  101. package/lib/credential-consumers/task-secrets.ts +10 -0
  102. package/lib/credential-store-pickup.ts +149 -0
  103. package/lib/credential-store-scope.ts +112 -0
  104. package/lib/credential-store.ts +932 -0
  105. package/lib/credentials-activation-authority.ts +709 -0
  106. package/lib/credentials-purge.ts +1006 -0
  107. package/lib/custom-connection-capture.ts +395 -0
  108. package/lib/custom-connection-launch.ts +384 -0
  109. package/lib/custom-connection-runtime.ts +141 -0
  110. package/lib/db.ts +80 -0
  111. package/lib/disk-pressure.ts +42 -0
  112. package/lib/docker-exec.ts +156 -0
  113. package/lib/engine-accounts.ts +1970 -0
  114. package/lib/engine-connect.ts +321 -0
  115. package/lib/engine-credential-env.ts +52 -0
  116. package/lib/engine-login.ts +2933 -0
  117. package/lib/engines.ts +1342 -0
  118. package/lib/enrollment-state.ts +251 -0
  119. package/lib/env-file.ts +246 -0
  120. package/lib/env-schema.ts +891 -0
  121. package/lib/env.ts +195 -0
  122. package/lib/fs-aliases.ts +49 -0
  123. package/lib/git-clone-credentials.ts +334 -0
  124. package/lib/git-diff.ts +530 -0
  125. package/lib/git-identity.ts +715 -0
  126. package/lib/github-git-auth.ts +207 -0
  127. package/lib/github-origin.ts +28 -0
  128. package/lib/github-tokens.ts +2439 -0
  129. package/lib/host-cloud-url.ts +62 -0
  130. package/lib/host-config.ts +279 -0
  131. package/lib/host-env.ts +424 -0
  132. package/lib/host-logs.ts +965 -0
  133. package/lib/internal-connections.ts +5 -0
  134. package/lib/keyed-promise-tail.ts +23 -0
  135. package/lib/launch-mode.ts +25 -0
  136. package/lib/lease.ts +239 -0
  137. package/lib/legacy-host.ts +769 -0
  138. package/lib/legacy-runtime-retirement.ts +424 -0
  139. package/lib/legacy-runtime-v1.fixture.ts +627 -0
  140. package/lib/machine-exec.ts +238 -0
  141. package/lib/machine-gateway-tunnel.ts +344 -0
  142. package/lib/machine-keys.ts +65 -0
  143. package/lib/machine-project-env-loader.ts +152 -0
  144. package/lib/machine-provider-aws.ts +923 -0
  145. package/lib/machine-provider-local.ts +338 -0
  146. package/lib/machine-provider.ts +90 -0
  147. package/lib/managed-activation-watcher.ts +72 -0
  148. package/lib/managed-install-owner-watcher.ts +55 -0
  149. package/lib/managed-operation-drain.ts +57 -0
  150. package/lib/managed-runtime.ts +4296 -0
  151. package/lib/managed-update-scheduler.ts +125 -0
  152. package/lib/mcp-capture.ts +865 -0
  153. package/lib/mcp-config-lock.ts +2 -0
  154. package/lib/mcp-connections.ts +959 -0
  155. package/lib/mcp-gateway.ts +2594 -0
  156. package/lib/obs.ts +512 -0
  157. package/lib/openrouter-budget.ts +247 -0
  158. package/lib/orchestrator.ts +11409 -0
  159. package/lib/person-capture.ts +327 -0
  160. package/lib/preview-ports.ts +85 -0
  161. package/lib/preview-sidecar.ts +331 -0
  162. package/lib/project-env-authority.ts +1319 -0
  163. package/lib/project-env-feature.ts +74 -0
  164. package/lib/project-env-legacy-tags.ts +130 -0
  165. package/lib/project-env-loader.ts +242 -0
  166. package/lib/project-env-replica.ts +2094 -0
  167. package/lib/project-env-schema-capture.ts +342 -0
  168. package/lib/project-env-staging.ts +148 -0
  169. package/lib/project-env-task-policy.ts +176 -0
  170. package/lib/project-env-task-replica.ts +1752 -0
  171. package/lib/release-feed.ts +61 -0
  172. package/lib/release-manifest.ts +710 -0
  173. package/lib/release-trust.ts +46 -0
  174. package/lib/repo-clone.ts +38 -0
  175. package/lib/routing/shadow.ts +261 -0
  176. package/lib/runner-boot.ts +10 -0
  177. package/lib/runtime-activation-tail.ts +279 -0
  178. package/lib/runtime-archive.ts +1216 -0
  179. package/lib/runtime-authority.ts +166 -0
  180. package/lib/runtime-guard.ts +36 -0
  181. package/lib/runtime-provider-state.ts +169 -0
  182. package/lib/runtime-reconnect.ts +86 -0
  183. package/lib/runtime-state.ts +1033 -0
  184. package/lib/safe-http.ts +364 -0
  185. package/lib/secrets-proxy/address.ts +95 -0
  186. package/lib/secrets-proxy/audit.ts +30 -0
  187. package/lib/secrets-proxy/ca-store.ts +200 -0
  188. package/lib/secrets-proxy/ca.ts +470 -0
  189. package/lib/secrets-proxy/container-steps.ts +138 -0
  190. package/lib/secrets-proxy/der.ts +273 -0
  191. package/lib/secrets-proxy/engine.ts +1633 -0
  192. package/lib/secrets-proxy/host.ts +93 -0
  193. package/lib/secrets-proxy/hostname.ts +57 -0
  194. package/lib/secrets-proxy/placeholders.ts +196 -0
  195. package/lib/secrets-proxy/policy.ts +150 -0
  196. package/lib/secrets-proxy/readiness.ts +148 -0
  197. package/lib/secrets-proxy/runtime.ts +582 -0
  198. package/lib/secrets-proxy/scrub.ts +559 -0
  199. package/lib/secrets-proxy/sidecar-view.ts +139 -0
  200. package/lib/secrets-proxy/task-scripts.ts +262 -0
  201. package/lib/secrets-proxy/task-trust.ts +605 -0
  202. package/lib/secrets-proxy/trust-watch.ts +80 -0
  203. package/lib/secrets.ts +71 -0
  204. package/lib/shared-files-s3.ts +976 -0
  205. package/lib/shared-files.ts +156 -0
  206. package/lib/sidecar-assign.ts +49 -0
  207. package/lib/sidecar-guard.ts +58 -0
  208. package/lib/sidecar-resume.ts +178 -0
  209. package/lib/sidecar.ts +69 -0
  210. package/lib/signin-box-credentials.ts +465 -0
  211. package/lib/signin-box.ts +114 -0
  212. package/lib/skill-index-example.fixture.ts +56 -0
  213. package/lib/skill-layout.ts +417 -0
  214. package/lib/skills.ts +489 -0
  215. package/lib/ssh.ts +401 -0
  216. package/lib/standard-image-cleanup.ts +117 -0
  217. package/lib/standard-image-pin.ts +207 -0
  218. package/lib/standard-image.ts +1697 -0
  219. package/lib/stopped-task-status-queue.ts +120 -0
  220. package/lib/task-container-cli.ts +269 -0
  221. package/lib/task-credential-cleanup.ts +752 -0
  222. package/lib/task-diff-sandbox.ts +464 -0
  223. package/lib/task-diff.ts +91 -0
  224. package/lib/task-env-forwarder.ts +228 -0
  225. package/lib/task-env-source.ts +249 -0
  226. package/lib/task-environment/anchor.ts +31 -0
  227. package/lib/task-environment/apple-container.ts +860 -0
  228. package/lib/task-environment/docker.ts +1344 -0
  229. package/lib/task-environment/index.ts +466 -0
  230. package/lib/task-environment/legacy-adoption.ts +467 -0
  231. package/lib/task-environment/machine-environment.ts +61 -0
  232. package/lib/task-environment/machine-task-up.ts +1227 -0
  233. package/lib/task-environment/machine.ts +974 -0
  234. package/lib/task-environment/registry.ts +59 -0
  235. package/lib/task-environment/self-identity.ts +20 -0
  236. package/lib/task-environment/self.ts +566 -0
  237. package/lib/task-environment/types.ts +642 -0
  238. package/lib/task-environment/workspace-files.ts +685 -0
  239. package/lib/task-environment/workspace-runtime.fixture.ts +50 -0
  240. package/lib/task-files-machine-retirement-outbox.ts +592 -0
  241. package/lib/task-files-machine-retirement.ts +116 -0
  242. package/lib/task-files-readiness.ts +127 -0
  243. package/lib/task-gateway-port.ts +22 -0
  244. package/lib/task-gateway-protocol.ts +190 -0
  245. package/lib/task-gateway-recovery.ts +47 -0
  246. package/lib/task-gateway-state.ts +512 -0
  247. package/lib/task-identity.ts +19 -0
  248. package/lib/task-inventory.ts +682 -0
  249. package/lib/task-machine-credentials-activation.ts +95 -0
  250. package/lib/task-machine-swap-safety.ts +729 -0
  251. package/lib/task-person-credentials.ts +76 -0
  252. package/lib/task-project-ops-host.ts +147 -0
  253. package/lib/task-project-ops.ts +1062 -0
  254. package/lib/task-secret-staging.ts +16 -0
  255. package/lib/task-status.ts +46 -0
  256. package/lib/task-token.ts +46 -0
  257. package/lib/token-broker-adopt.ts +344 -0
  258. package/lib/token-broker-lambda.ts +105 -0
  259. package/lib/token-broker-local.ts +392 -0
  260. package/lib/token-broker-revision.ts +310 -0
  261. package/lib/token-broker-ssm.ts +525 -0
  262. package/lib/token-broker.ts +218 -0
  263. package/lib/token-lanes/openai-oauth.ts +123 -0
  264. package/lib/token-lanes/registry.ts +45 -0
  265. package/lib/token-lanes/types.ts +163 -0
  266. package/lib/token-lanes/xai-oauth.ts +165 -0
  267. package/lib/transcript.ts +128 -0
  268. package/lib/tunnel-registry.ts +220 -0
  269. package/lib/tunnel-runtime.ts +235 -0
  270. package/lib/ulid.ts +16 -0
  271. package/lib/user-bus-env.ts +116 -0
  272. package/lib/user-credentials-store.ts +435 -0
  273. package/lib/vnode-pressure.ts +142 -0
  274. package/package.json +131 -4
  275. package/runner/runner.mjs +442 -0
  276. package/scripts/agent/_common.sh +633 -0
  277. package/scripts/agent/env-hook.sh +626 -0
  278. package/scripts/agent/moondeck-cli.sh +5 -0
  279. package/scripts/agent/task-down.sh +291 -0
  280. package/scripts/agent/task-status.sh +70 -0
  281. package/scripts/agent/task-up.sh +3761 -0
  282. package/scripts/agent/uai-env-exec.sh +26 -0
  283. package/scripts/install/darwin.ts +1048 -0
  284. package/scripts/install/linux.ts +958 -0
  285. package/scripts/install/types.ts +78 -0
  286. package/scripts/install/util.ts +254 -0
  287. package/scripts/install/win.ts +143 -0
  288. package/src/activation-respawn.ts +155 -0
  289. package/src/apple-tunnel-route.ts +104 -0
  290. package/src/capture-wire.ts +102 -0
  291. package/src/cli.ts +2583 -0
  292. package/src/cloud-credentials.ts +2144 -0
  293. package/src/cloud-origins.ts +23 -0
  294. package/src/credential-location.ts +248 -0
  295. package/src/credential-wire-validation.ts +25 -0
  296. package/src/custom-connections.ts +237 -0
  297. package/src/engine-account-paths.ts +11 -0
  298. package/src/engine-models.ts +39 -0
  299. package/src/engine-usage.ts +104 -0
  300. package/src/event-outbox.ts +185 -0
  301. package/src/index.ts +2899 -0
  302. package/src/load-env.ts +97 -0
  303. package/src/machine-tunnel-route.ts +156 -0
  304. package/src/main.ts +6349 -0
  305. package/src/never-proxied-domains.ts +31 -0
  306. package/src/outbound-frame.ts +31 -0
  307. package/src/paths.ts +81 -0
  308. package/src/project-env-latest.ts +130 -0
  309. package/src/project-env-policy.ts +396 -0
  310. package/src/project-env-wording.ts +53 -0
  311. package/src/protocol.ts +5545 -0
  312. package/src/runtime-bootstrap.ts +165 -0
  313. package/src/station-identity.ts +96 -0
  314. package/src/task-env-names.ts +28 -0
  315. package/src/task-secrets.ts +75 -0
  316. package/src/tunnel-dial.ts +102 -0
  317. package/src/ui/server.ts +852 -0
  318. package/src/ui/types.ts +173 -0
  319. package/src/workspace-path-policy.ts +41 -0
  320. package/ui/app.js +1102 -0
  321. package/ui/index.html +90 -0
  322. package/ui/style.css +685 -0
  323. package/ui/uai-favicon.svg +21 -0
  324. package/ui/uai-logo-black.svg +9 -0
  325. package/ui/uai-wheel.svg +9 -0
@@ -0,0 +1,417 @@
1
+ /**
2
+ * What an agent's link and document skills write (ADR-046, ADR-166): the
3
+ * flat SKILL.md, or an index with a file per skill beside it. Pure, with no
4
+ * file, process or runtime access, so the cloud's tests and build can use it
5
+ * too; `skills.ts` writes what this renders.
6
+ */
7
+ import type { RosterAgent, RosterSkill } from "./agents/types";
8
+
9
+ /** Skills that go into the flat per-agent SKILL.md — link/document only. The
10
+ * ADR-047 `package` skills install into the container's skills dir instead. */
11
+ export function flatSkills(agent: RosterAgent): RosterSkill[] {
12
+ return (agent.skills ?? []).filter((s) => s.type !== "package");
13
+ }
14
+
15
+
16
+ /** A url-safe folder name for a skill (the dir under ~/.claude/skills/). */
17
+ export function skillSlug(name: string): string {
18
+ return (
19
+ name
20
+ .toLowerCase()
21
+ .replace(/[^a-z0-9._-]+/g, "-")
22
+ .replace(/^[-.]+|[-.]+$/g, "")
23
+ .slice(0, 64) || "skill"
24
+ );
25
+ }
26
+
27
+
28
+ /** Render an agent's skills into SKILL.md markdown ("everything in one file"). */
29
+ export function renderSkillMd(agent: RosterAgent): string {
30
+ const skills = flatSkills(agent);
31
+ const lines = [
32
+ `# ${agent.label} — skills`,
33
+ "",
34
+ "Reference material prepared for you (links and documents). Consult it when",
35
+ "a task touches what it covers.",
36
+ "",
37
+ ];
38
+ for (const s of skills) {
39
+ lines.push(`## ${s.name}`, "");
40
+ if (s.type === "link") lines.push(`Link: ${s.value}`, "");
41
+ else lines.push(s.value.trim(), "");
42
+ }
43
+ return `${lines.join("\n").trimEnd()}\n`;
44
+ }
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // ADR-166 skill files: SKILL.md becomes an index and each document skill its
48
+ // own file beside it, so an agent reads a skill whole when its work comes up
49
+ // instead of one long file a `cat` or `head` cuts short.
50
+ // ---------------------------------------------------------------------------
51
+
52
+ /** The folder, beside SKILL.md, that holds an indexed agent's skill files. */
53
+ export const SKILL_FILES_DIR = "skills";
54
+ /** The list of files the last write put there, so a later layout removes stale ones. */
55
+ export const SKILL_FILES_MANIFEST = ".files";
56
+ /** Past this many lines a flat SKILL.md becomes an index, even for one document. */
57
+ export const SKILL_INDEX_THRESHOLD_LINES = 150;
58
+ /**
59
+ * Each file in `skills/` is at most this many lines and bytes, so one `cat`
60
+ * shows it whole on every engine: shell output is cut, and some engines show
61
+ * only the head and tail of a long one. A longer document is written in parts.
62
+ */
63
+ export const SKILL_FILE_MAX_LINES = 150;
64
+ export const SKILL_FILE_MAX_BYTES = 8 * 1024;
65
+ /**
66
+ * The marker a document carries before each of its own per-action files (the
67
+ * Astronaut's guide). A comment, so a runner that predates the split shows
68
+ * the document flat and unchanged.
69
+ */
70
+ const SKILL_FILE_MARKER = /^<!-- skill-file: (\S+) -->$/gm;
71
+ export const SKILL_FILE_NAME = /^[a-z0-9][a-z0-9-]{0,39}\.md$/;
72
+ /** The longest base a skill file may have: what SKILL_FILE_NAME allows before `.md`. */
73
+ const MAX_FILE_BASE = 40;
74
+
75
+ /** Whether `name` is a file the runner writes in `skills/` (and its manifest may name). */
76
+ export function isSkillFileName(name: string): boolean {
77
+ return SKILL_FILE_NAME.test(name);
78
+ }
79
+
80
+ /** `<base><suffix>.md`, the base cut so the whole name stays valid. */
81
+ function fileName(base: string, suffix = ""): string {
82
+ const cut = base.slice(0, MAX_FILE_BASE - suffix.length).replace(/-+$/, "") || "skill";
83
+ const name = `${cut}${suffix}.md`;
84
+ if (!isSkillFileName(name)) throw new SkillLayoutError("name");
85
+ return name;
86
+ }
87
+ const MAX_SKILL_FILES = 64;
88
+
89
+ /**
90
+ * A document split at its markers: the part before the first is its index,
91
+ * and each marker starts a file that runs to the next one. Null when it has
92
+ * no markers, or any marker names a file badly or twice, so a malformed
93
+ * document is written whole rather than half split.
94
+ */
95
+ export function splitSkillDocument(value: string): { index: string; files: { name: string; content: string }[] } | null {
96
+ const markers = [...value.matchAll(SKILL_FILE_MARKER)];
97
+ if (markers.length === 0 || markers.length > MAX_SKILL_FILES) return null;
98
+ const names = markers.map((marker) => marker[1]!);
99
+ if (names.some((name) => !isSkillFileName(name)) || new Set(names).size !== names.length) return null;
100
+ const files = markers.map((marker, i) => {
101
+ const start = marker.index! + marker[0].length;
102
+ const end = i + 1 < markers.length ? markers[i + 1]!.index! : value.length;
103
+ return { name: names[i]!, content: `${value.slice(start, end).trim()}\n` };
104
+ });
105
+ return { index: value.slice(0, markers[0]!.index!).trim(), files };
106
+ }
107
+
108
+ /** A skill's one-line description: its first sentence, as plain text. */
109
+ export function skillSummary(value: string): string {
110
+ const line = value
111
+ .split("\n")
112
+ .map((l) => l.trim())
113
+ .find((l) => l && !l.startsWith("#") && !l.startsWith("<!--") && !l.startsWith("```") && !/^[-*_]{3,}$/.test(l));
114
+ if (!line) return "";
115
+ const text = line.replace(/^[-*>]\s+/, "").replace(/[*_`]/g, "").replace(/\[([^\]]*)\]\([^)]*\)/g, "$1");
116
+ const sentence = /^(.*?[.!?])(?:\s|$)/.exec(text)?.[1] ?? text;
117
+ return sentence.length > 140 ? `${sentence.slice(0, 137).trimEnd()}…` : sentence;
118
+ }
119
+
120
+ /** A file name for a skill: its slug, made unique among names already taken. */
121
+ function uniqueFileName(name: string, taken: Set<string>): string {
122
+ const base = skillSlug(name).replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "") || "skill";
123
+ let candidate = fileName(base);
124
+ for (let n = 2; taken.has(candidate); n++) candidate = fileName(base, `-${n}`);
125
+ taken.add(candidate);
126
+ return candidate;
127
+ }
128
+
129
+ const lineCount = (text: string) => text.replace(/\n$/, "").split("\n").length;
130
+ /** Whether a file's exact content fits the budgets: its UTF-8 bytes and its lines. */
131
+ export function fitsSkillFileBudget(content: string): boolean {
132
+ return lineCount(content) <= SKILL_FILE_MAX_LINES && Buffer.byteLength(content) <= SKILL_FILE_MAX_BYTES;
133
+ }
134
+
135
+ /** A Markdown line, or a fenced code block kept whole. */
136
+ type Block =
137
+ | { kind: "line"; text: string }
138
+ | { kind: "fence"; open: string; body: string[]; close: string };
139
+
140
+ const FENCE_OPEN = /^ {0,3}(`{3,}|~{3,})/;
141
+
142
+ /** Lines grouped so that a fenced block is one unit; an unclosed fence runs to the end. */
143
+ function blocksOf(text: string): Block[] {
144
+ const lines = text.split("\n");
145
+ const blocks: Block[] = [];
146
+ for (let i = 0; i < lines.length; i++) {
147
+ const open = FENCE_OPEN.exec(lines[i]!);
148
+ if (!open) {
149
+ blocks.push({ kind: "line", text: lines[i]! });
150
+ continue;
151
+ }
152
+ const marker = open[1]!;
153
+ const closes = new RegExp(`^ {0,3}${marker[0] === "`" ? "`" : "~"}{${marker.length},}\\s*$`);
154
+ const body: string[] = [];
155
+ let j = i + 1;
156
+ while (j < lines.length && !closes.test(lines[j]!)) body.push(lines[j++]!);
157
+ // Unclosed in the source: closed here, so no part is left inside a fence.
158
+ blocks.push({ kind: "fence", open: lines[i]!, body, close: j < lines.length ? lines[j]! : marker });
159
+ i = j;
160
+ }
161
+ return blocks;
162
+ }
163
+
164
+ const renderBlocks = (blocks: Block[]) =>
165
+ blocks.map((b) => (b.kind === "line" ? b.text : [b.open, ...b.body, b.close].join("\n"))).join("\n");
166
+
167
+ /** `units` split before each block `starts` marks, keeping the block. */
168
+ function splitBefore(blocks: Block[], starts: (b: Block) => boolean): Block[][] {
169
+ const out: Block[][] = [];
170
+ for (const block of blocks) {
171
+ if (out.length === 0 || starts(block)) out.push([]);
172
+ out[out.length - 1]!.push(block);
173
+ }
174
+ return out;
175
+ }
176
+
177
+ /** Text over `bytes` cut at characters, never inside a UTF-8 sequence. */
178
+ function cutChars(text: string, bytes: number): string[] {
179
+ const out: string[] = [];
180
+ let piece = "";
181
+ for (const ch of text) {
182
+ if (piece && Buffer.byteLength(piece + ch) > bytes) {
183
+ out.push(piece);
184
+ piece = "";
185
+ }
186
+ piece += ch;
187
+ }
188
+ return piece ? [...out, piece] : out;
189
+ }
190
+
191
+ /**
192
+ * Text cut into pieces that each fit `lines` and `bytes`. Cuts fall only
193
+ * outside fenced code: at headings, then blank lines, then lines. A fenced
194
+ * block too big for one piece is cut between its lines, each fragment closed
195
+ * and reopened with the same fence and info string. A line too long for one
196
+ * piece is cut at characters. Units are cut only as finely as they need,
197
+ * then packed in order, each keeping the separator it had.
198
+ */
199
+ function cutToBudget(text: string, lines: number, bytes: number): string[] {
200
+ const fits = (t: string) => t.split("\n").length <= lines && Buffer.byteLength(t) <= bytes;
201
+ const atoms: { text: string; sep: string }[] = [];
202
+ const add = (blocks: Block[], sep: string, level: number) => {
203
+ const rendered = renderBlocks(blocks);
204
+ if (fits(rendered)) {
205
+ atoms.push({ text: rendered, sep });
206
+ return;
207
+ }
208
+ if (level === 0) {
209
+ splitBefore(blocks, (b) => b.kind === "line" && /^#{1,6} /.test(b.text)).forEach((unit, i) =>
210
+ add(unit, i === 0 ? sep : "\n", 1),
211
+ );
212
+ return;
213
+ }
214
+ if (level === 1) {
215
+ // Paragraphs: runs between blank lines outside fences.
216
+ const paragraphs: Block[][] = [[]];
217
+ for (const block of blocks) {
218
+ if (block.kind === "line" && block.text.trim() === "") {
219
+ if (paragraphs[paragraphs.length - 1]!.length) paragraphs.push([]);
220
+ } else paragraphs[paragraphs.length - 1]!.push(block);
221
+ }
222
+ paragraphs.filter((p) => p.length).forEach((unit, i) => add(unit, i === 0 ? sep : "\n\n", 2));
223
+ return;
224
+ }
225
+ if (level === 2 && blocks.length > 1) {
226
+ blocks.forEach((block, i) => add([block], i === 0 ? sep : "\n", 3));
227
+ return;
228
+ }
229
+ const block = blocks[0]!;
230
+ if (block.kind === "line") {
231
+ cutChars(block.text, bytes).forEach((piece, i) => atoms.push({ text: piece, sep: i === 0 ? sep : "\n" }));
232
+ return;
233
+ }
234
+ // A fence too big for one piece: fragments of its lines, each fenced.
235
+ const frame = `${block.open}\n\n${block.close}`;
236
+ const room = { lines: lines - 2, bytes: bytes - Buffer.byteLength(frame) };
237
+ const bodyLines = block.body.flatMap((line) => (Buffer.byteLength(line) > room.bytes ? cutChars(line, room.bytes) : [line]));
238
+ let group: string[] = [];
239
+ const flush = (first: boolean) => {
240
+ atoms.push({ text: [block.open, ...group, block.close].join("\n"), sep: first ? sep : "\n" });
241
+ group = [];
242
+ };
243
+ let first = true;
244
+ for (const line of bodyLines) {
245
+ const next = [...group, line];
246
+ if (group.length && (next.length > room.lines || Buffer.byteLength(next.join("\n")) > room.bytes)) {
247
+ flush(first);
248
+ first = false;
249
+ }
250
+ group.push(line);
251
+ }
252
+ flush(first);
253
+ };
254
+ add(blocksOf(text), "", 0);
255
+ const pieces: string[] = [];
256
+ let current = "";
257
+ for (const atom of atoms) {
258
+ const joined = current ? `${current}${atom.sep}${atom.text}` : atom.text;
259
+ if (current && !fits(joined)) {
260
+ pieces.push(current);
261
+ current = atom.text;
262
+ } else {
263
+ current = joined;
264
+ }
265
+ }
266
+ if (current) pieces.push(current);
267
+ return pieces.map((piece) => piece.replace(/^\n+/, "").trimEnd()).filter((piece) => piece.trim());
268
+ }
269
+
270
+ /**
271
+ * One file's content as the files to write: itself when it fits the
272
+ * budgets, else parts named `<base>-part-<n>.md`, each but the last naming
273
+ * the next. Every file is measured as written, framing included, and each
274
+ * name passes the manifest's own check.
275
+ */
276
+ function withinBudget(name: string, title: string, content: string, taken: Set<string>): { name: string; content: string }[] {
277
+ if (fitsSkillFileBudget(content)) return [{ name, content }];
278
+ const base = name.replace(/\.md$/, "");
279
+ const heading = title.length > 80 ? `${title.slice(0, 79)}…` : title;
280
+ // The most a part's framing adds: its heading, its "continued in" line and the final newline.
281
+ const longestName = "x".repeat(MAX_FILE_BASE) + ".md";
282
+ const frame = (n: string, total: number) => ({
283
+ head: `# ${heading} (part ${n} of ${total})\n\n`,
284
+ next: (to: string) => `\n\nContinued in \`${SKILL_FILES_DIR}/${to}\`; read it too.`,
285
+ });
286
+ for (let shrink = 0; shrink < 8; shrink++) {
287
+ const reserve = frame("9999", 9999);
288
+ const reservedBytes = Buffer.byteLength(reserve.head) + Buffer.byteLength(reserve.next(longestName)) + 1;
289
+ const scale = 1 - shrink * 0.1;
290
+ const pieces = cutToBudget(
291
+ content.trimEnd(),
292
+ Math.floor((SKILL_FILE_MAX_LINES - 4) * scale),
293
+ Math.floor((SKILL_FILE_MAX_BYTES - reservedBytes) * scale),
294
+ );
295
+ const claimed = new Set<string>();
296
+ const names = pieces.map((_, i) => {
297
+ if (i === 0) return name;
298
+ let candidate = fileName(base, `-part-${i + 1}`);
299
+ for (let n = 2; taken.has(candidate) || claimed.has(candidate); n++) candidate = fileName(base, `-part-${i + 1}-${n}`);
300
+ claimed.add(candidate);
301
+ return candidate;
302
+ });
303
+ const parts = pieces.map((piece, i) => {
304
+ const { head, next } = frame(String(i + 1), pieces.length);
305
+ return {
306
+ name: names[i]!,
307
+ content: `${i === 0 ? "" : head}${piece}${i + 1 < pieces.length ? next(names[i + 1]!) : ""}\n`,
308
+ };
309
+ });
310
+ if (parts.every((part) => fitsSkillFileBudget(part.content))) {
311
+ for (const n of claimed) taken.add(n);
312
+ return parts;
313
+ }
314
+ }
315
+ throw new SkillLayoutError("budget");
316
+ }
317
+
318
+ export interface SkillLayout {
319
+ /** SKILL.md: today's flat file, or the index. */
320
+ skillMd: string;
321
+ /** Files for `skills/`, empty when SKILL.md is flat. */
322
+ files: { name: string; content: string }[];
323
+ /** The skills the index names, in order; empty when flat. */
324
+ indexed: string[];
325
+ }
326
+
327
+ /**
328
+ * What an agent's skills write. Flat, exactly `renderSkillMd`, while the
329
+ * agent has at most one document skill, a flat file within
330
+ * SKILL_INDEX_THRESHOLD_LINES lines and SKILL_FILE_MAX_BYTES, and no split
331
+ * document. Otherwise SKILL.md
332
+ * is an index: one line per skill (a link inline, a document with its first
333
+ * sentence and its file), and the built-in guide's own index in its place
334
+ * when it carries markers, with its per-action files beside the others'. File names are the skill's
335
+ * slug, numbered when two skills share one, never one a split file took.
336
+ */
337
+ export function renderSkillLayout(agent: RosterAgent): SkillLayout {
338
+ try {
339
+ return indexedOrFlat(agent);
340
+ } catch (error) {
341
+ // Never let a layout problem stop an agent: today's flat file, and one
342
+ // bounded line saying so (the agent, why, the size; never skill text).
343
+ const skillMd = renderSkillMd(agent);
344
+ const reason = error instanceof SkillLayoutError ? error.reason : "unexpected";
345
+ console.warn(
346
+ `[skills] ${agent.id.slice(0, 64)}: skill index fell back to a flat SKILL.md (${reason}); ${Buffer.byteLength(skillMd)} bytes`,
347
+ );
348
+ return { skillMd, files: [], indexed: [] };
349
+ }
350
+ }
351
+
352
+ /** Why a layout could not be built; only the reason is ever logged. */
353
+ class SkillLayoutError extends Error {
354
+ constructor(readonly reason: "budget" | "name") {
355
+ super(reason === "budget" ? "a part could not fit the skill file budgets" : "a generated file name was not valid");
356
+ }
357
+ }
358
+
359
+ function indexedOrFlat(agent: RosterAgent): SkillLayout {
360
+ const skills = flatSkills(agent);
361
+ const flat = renderSkillMd(agent);
362
+ const documents = skills.filter((s) => s.type === "document");
363
+ // Only the first skill, the built-in guide the server puts first, is ever
364
+ // split at its markers; a workspace's or persona's own document is one file.
365
+ const first = skills[0];
366
+ const split = first?.type === "document" ? splitSkillDocument(first.value) : null;
367
+ if (
368
+ documents.length < 2 &&
369
+ !split &&
370
+ flat.trimEnd().split("\n").length <= SKILL_INDEX_THRESHOLD_LINES &&
371
+ Buffer.byteLength(flat) <= SKILL_FILE_MAX_BYTES
372
+ ) {
373
+ return { skillMd: flat, files: [], indexed: [] };
374
+ }
375
+ // The split's file names are its own, so they are taken first.
376
+ const taken = new Set<string>([SKILL_FILES_MANIFEST, ...(split?.files.map((f) => f.name) ?? [])]);
377
+ const files: SkillLayout["files"] = [];
378
+ const lines = [
379
+ `# ${agent.label} — skills`,
380
+ "",
381
+ "An index of the reference material prepared for you. Each document is its own",
382
+ `file in \`${SKILL_FILES_DIR}/\` beside this one. Before work a skill covers, read its`,
383
+ "file in full, for example with `cat`.",
384
+ "",
385
+ ];
386
+ /** The built-in guide's own index, after the other skills' one-line entries. */
387
+ const guide: string[] = [];
388
+ /** One line, whatever the stored name or link holds. */
389
+ const oneLine = (text: string) => text.replace(/\s+/g, " ").trim();
390
+ for (const skill of skills) {
391
+ const title = oneLine(skill.name);
392
+ if (skill.type === "link") {
393
+ lines.push(`- **${title}**: Link: ${oneLine(skill.value)}`);
394
+ } else if (skill === first && split) {
395
+ guide.push("", `## ${title}`, "", split.index);
396
+ files.unshift(...split.files.flatMap((f) => withinBudget(f.name, f.name.replace(/\.md$/, ""), f.content, taken)));
397
+ } else {
398
+ const name = uniqueFileName(skill.name, taken);
399
+ const summary = skillSummary(skill.value);
400
+ const body = skill.value.trim();
401
+ const parts = withinBudget(name, title, `${body.startsWith("# ") ? body : `# ${title}\n\n${body}`}\n`, taken);
402
+ const paths = parts.map((part) => `\`${SKILL_FILES_DIR}/${part.name}\``).join(", then ");
403
+ lines.push(`- **${title}**${summary ? ` — covers: ${summary}` : ""} → ${paths}`);
404
+ files.push(...parts);
405
+ }
406
+ }
407
+ return {
408
+ skillMd: `${[...lines, ...guide].join("\n").replace(/\n{3,}/g, "\n\n").trimEnd()}\n`,
409
+ files,
410
+ indexed: skills.map((s) => s.name),
411
+ };
412
+ }
413
+
414
+ /** Whether the agent's SKILL.md is an index, with its skills in files beside it. */
415
+ export function hasSkillIndex(agent: RosterAgent): boolean {
416
+ return renderSkillLayout(agent).indexed.length > 0;
417
+ }