@moondeck/station 0.0.1 → 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.
- package/LICENSE +21 -0
- package/README.md +149 -2
- package/bin/uai-host.mjs +130 -0
- package/db/migrations/0000_host_tasks.sql +12 -0
- package/db/migrations/0001_host_ui.sql +11 -0
- package/db/migrations/0002_host_github_tokens.sql +8 -0
- package/db/migrations/0003_host_ssh_keys.sql +8 -0
- package/db/migrations/0004_host_owner_name.sql +1 -0
- package/db/migrations/0005_host_env_vars.sql +5 -0
- package/db/migrations/0006_host_project_env.sql +9 -0
- package/db/migrations/0007_host_github_token_kind.sql +7 -0
- package/db/migrations/0008_host_mcp_connections.sql +18 -0
- package/db/migrations/0009_host_agent_sessions.sql +15 -0
- package/db/migrations/0010_host_engine_accounts.sql +13 -0
- package/db/migrations/0011_codex_thread_id.sql +5 -0
- package/db/migrations/0012_agent_session_attach_key.sql +4 -0
- package/db/migrations/0013_github_credential_generations.sql +11 -0
- package/db/migrations/0014_host_inventory_event_index.sql +1 -0
- package/db/migrations/0015_host_settings.sql +9 -0
- package/db/migrations/0016_task_environment.sql +2 -0
- package/db/migrations/0017_engine_account_scope.sql +7 -0
- package/db/migrations/0018_engine_account_position.sql +3 -0
- package/db/migrations/0019_engine_account_store_ref.sql +3 -0
- package/db/migrations/0020_user_credential_store_ref.sql +6 -0
- package/db/migrations/0021_project_env_store_ref.sql +3 -0
- package/db/migrations/0022_task_project_ids.sql +5 -0
- package/db/migrations/0023_project_env_org.sql +4 -0
- package/db/migrations/0024_task_env_mode.sql +9 -0
- package/db/migrations/0025_project_env_authority.sql +147 -0
- package/db/migrations/0026_project_env_replica.sql +150 -0
- package/db/migrations/0027_cloud_credential_foundation.sql +51 -0
- package/db/migrations/0028_leased_engine_runtimes.sql +11 -0
- package/db/migrations/0029_task_shared_files_mode.sql +5 -0
- package/db/migrations/0030_credential_cleanup_restops.sql +12 -0
- package/db/migrations/meta/_journal.json +223 -0
- package/db/schema.ts +850 -0
- package/images/standard/Dockerfile +347 -0
- package/images/standard/README.md +167 -0
- package/images/standard/container/code-server-settings.json +63 -0
- package/images/standard/container/corepack-version +1 -0
- package/images/standard/container/uai-init +704 -0
- package/images/standard/container/uai-materialize-runtimes +1527 -0
- package/images/standard/container/uai-task-forwarder.mjs +303 -0
- package/images/standard/tool-versions +2 -0
- package/lib/__fixtures__/skill-index/SKILL.md +10 -0
- package/lib/__fixtures__/skill-index/skills/accessibility-checklist.md +7 -0
- package/lib/__fixtures__/skill-index/skills/brand-voice.md +9 -0
- package/lib/__fixtures__/skill-index/skills/design-tokens.md +9 -0
- package/lib/agent-cli.ts +1507 -0
- package/lib/agent-errors.ts +22 -0
- package/lib/agent.ts +672 -0
- package/lib/agents/attach-compatibility.ts +51 -0
- package/lib/agents/claude.ts +918 -0
- package/lib/agents/codex-token-usage.fixture.json +130 -0
- package/lib/agents/codex.ts +1392 -0
- package/lib/agents/cursor-period-usage.ts +253 -0
- package/lib/agents/cursor.ts +562 -0
- package/lib/agents/dispatch.ts +78 -0
- package/lib/agents/durable-proc.ts +334 -0
- package/lib/agents/factory.ts +57 -0
- package/lib/agents/grok.ts +381 -0
- package/lib/agents/kimi.ts +329 -0
- package/lib/agents/machine-durable.ts +604 -0
- package/lib/agents/mock.ts +133 -0
- package/lib/agents/mode.ts +64 -0
- package/lib/agents/opencode.ts +508 -0
- package/lib/agents/output-source.ts +3 -0
- package/lib/agents/proc.ts +221 -0
- package/lib/agents/rate-limit.ts +337 -0
- package/lib/agents/registry.ts +182 -0
- package/lib/agents/session-state.ts +197 -0
- package/lib/agents/skill-index-preamble.fixture.ts +26 -0
- package/lib/agents/transport.ts +741 -0
- package/lib/agents/types.ts +254 -0
- package/lib/agents/uai.ts +106 -0
- package/lib/agents/usage.ts +44 -0
- package/lib/apple-one-off.ts +277 -0
- package/lib/apple-runtime-recycle.ts +237 -0
- package/lib/apple-uninstall-teardown.ts +224 -0
- package/lib/assignment-evidence.ts +94 -0
- package/lib/attachments.ts +48 -0
- package/lib/browser-testing.ts +1537 -0
- package/lib/capture-flow.ts +332 -0
- package/lib/cloud-credential-cleanup-adapter.ts +123 -0
- package/lib/cloud-credential-host.ts +512 -0
- package/lib/cloud-credential-leases.ts +2587 -0
- package/lib/cloud-credential-runtime.ts +1357 -0
- package/lib/cloud-state.ts +56 -0
- package/lib/codex-auth.ts +456 -0
- package/lib/command-db.ts +310 -0
- package/lib/compose-network-recovery.ts +283 -0
- package/lib/connection-secret-redaction.ts +25 -0
- package/lib/container-runtime.ts +1453 -0
- package/lib/credential-cleanup-inventory.ts +490 -0
- package/lib/credential-consumers/engine-anchor.ts +1880 -0
- package/lib/credential-consumers/engines.ts +797 -0
- package/lib/credential-consumers/files.ts +934 -0
- package/lib/credential-consumers/github.ts +702 -0
- package/lib/credential-consumers/mcp.ts +389 -0
- package/lib/credential-consumers/task-files.ts +127 -0
- package/lib/credential-consumers/task-secrets.ts +10 -0
- package/lib/credential-store-pickup.ts +149 -0
- package/lib/credential-store-scope.ts +112 -0
- package/lib/credential-store.ts +932 -0
- package/lib/credentials-activation-authority.ts +709 -0
- package/lib/credentials-purge.ts +1006 -0
- package/lib/custom-connection-capture.ts +395 -0
- package/lib/custom-connection-launch.ts +384 -0
- package/lib/custom-connection-runtime.ts +141 -0
- package/lib/db.ts +80 -0
- package/lib/disk-pressure.ts +42 -0
- package/lib/docker-exec.ts +156 -0
- package/lib/engine-accounts.ts +1970 -0
- package/lib/engine-connect.ts +321 -0
- package/lib/engine-credential-env.ts +52 -0
- package/lib/engine-login.ts +2933 -0
- package/lib/engines.ts +1342 -0
- package/lib/enrollment-state.ts +251 -0
- package/lib/env-file.ts +246 -0
- package/lib/env-schema.ts +891 -0
- package/lib/env.ts +195 -0
- package/lib/fs-aliases.ts +49 -0
- package/lib/git-clone-credentials.ts +334 -0
- package/lib/git-diff.ts +530 -0
- package/lib/git-identity.ts +715 -0
- package/lib/github-git-auth.ts +207 -0
- package/lib/github-origin.ts +28 -0
- package/lib/github-tokens.ts +2439 -0
- package/lib/host-cloud-url.ts +62 -0
- package/lib/host-config.ts +279 -0
- package/lib/host-env.ts +424 -0
- package/lib/host-logs.ts +965 -0
- package/lib/internal-connections.ts +5 -0
- package/lib/keyed-promise-tail.ts +23 -0
- package/lib/launch-mode.ts +25 -0
- package/lib/lease.ts +239 -0
- package/lib/legacy-host.ts +769 -0
- package/lib/legacy-runtime-retirement.ts +424 -0
- package/lib/legacy-runtime-v1.fixture.ts +627 -0
- package/lib/machine-exec.ts +238 -0
- package/lib/machine-gateway-tunnel.ts +344 -0
- package/lib/machine-keys.ts +65 -0
- package/lib/machine-project-env-loader.ts +152 -0
- package/lib/machine-provider-aws.ts +923 -0
- package/lib/machine-provider-local.ts +338 -0
- package/lib/machine-provider.ts +90 -0
- package/lib/managed-activation-watcher.ts +72 -0
- package/lib/managed-install-owner-watcher.ts +55 -0
- package/lib/managed-operation-drain.ts +57 -0
- package/lib/managed-runtime.ts +4296 -0
- package/lib/managed-update-scheduler.ts +125 -0
- package/lib/mcp-capture.ts +865 -0
- package/lib/mcp-config-lock.ts +2 -0
- package/lib/mcp-connections.ts +959 -0
- package/lib/mcp-gateway.ts +2594 -0
- package/lib/obs.ts +512 -0
- package/lib/openrouter-budget.ts +247 -0
- package/lib/orchestrator.ts +11409 -0
- package/lib/person-capture.ts +327 -0
- package/lib/preview-ports.ts +85 -0
- package/lib/preview-sidecar.ts +331 -0
- package/lib/project-env-authority.ts +1319 -0
- package/lib/project-env-feature.ts +74 -0
- package/lib/project-env-legacy-tags.ts +130 -0
- package/lib/project-env-loader.ts +242 -0
- package/lib/project-env-replica.ts +2094 -0
- package/lib/project-env-schema-capture.ts +342 -0
- package/lib/project-env-staging.ts +148 -0
- package/lib/project-env-task-policy.ts +176 -0
- package/lib/project-env-task-replica.ts +1752 -0
- package/lib/release-feed.ts +61 -0
- package/lib/release-manifest.ts +710 -0
- package/lib/release-trust.ts +46 -0
- package/lib/repo-clone.ts +38 -0
- package/lib/routing/shadow.ts +261 -0
- package/lib/runner-boot.ts +10 -0
- package/lib/runtime-activation-tail.ts +279 -0
- package/lib/runtime-archive.ts +1216 -0
- package/lib/runtime-authority.ts +166 -0
- package/lib/runtime-guard.ts +36 -0
- package/lib/runtime-provider-state.ts +169 -0
- package/lib/runtime-reconnect.ts +86 -0
- package/lib/runtime-state.ts +1033 -0
- package/lib/safe-http.ts +364 -0
- package/lib/secrets-proxy/address.ts +95 -0
- package/lib/secrets-proxy/audit.ts +30 -0
- package/lib/secrets-proxy/ca-store.ts +200 -0
- package/lib/secrets-proxy/ca.ts +470 -0
- package/lib/secrets-proxy/container-steps.ts +138 -0
- package/lib/secrets-proxy/der.ts +273 -0
- package/lib/secrets-proxy/engine.ts +1633 -0
- package/lib/secrets-proxy/host.ts +93 -0
- package/lib/secrets-proxy/hostname.ts +57 -0
- package/lib/secrets-proxy/placeholders.ts +196 -0
- package/lib/secrets-proxy/policy.ts +150 -0
- package/lib/secrets-proxy/readiness.ts +148 -0
- package/lib/secrets-proxy/runtime.ts +582 -0
- package/lib/secrets-proxy/scrub.ts +559 -0
- package/lib/secrets-proxy/sidecar-view.ts +139 -0
- package/lib/secrets-proxy/task-scripts.ts +262 -0
- package/lib/secrets-proxy/task-trust.ts +605 -0
- package/lib/secrets-proxy/trust-watch.ts +80 -0
- package/lib/secrets.ts +71 -0
- package/lib/shared-files-s3.ts +976 -0
- package/lib/shared-files.ts +156 -0
- package/lib/sidecar-assign.ts +49 -0
- package/lib/sidecar-guard.ts +58 -0
- package/lib/sidecar-resume.ts +178 -0
- package/lib/sidecar.ts +69 -0
- package/lib/signin-box-credentials.ts +465 -0
- package/lib/signin-box.ts +114 -0
- package/lib/skill-index-example.fixture.ts +56 -0
- package/lib/skill-layout.ts +417 -0
- package/lib/skills.ts +489 -0
- package/lib/ssh.ts +401 -0
- package/lib/standard-image-cleanup.ts +117 -0
- package/lib/standard-image-pin.ts +207 -0
- package/lib/standard-image.ts +1697 -0
- package/lib/stopped-task-status-queue.ts +120 -0
- package/lib/task-container-cli.ts +269 -0
- package/lib/task-credential-cleanup.ts +752 -0
- package/lib/task-diff-sandbox.ts +464 -0
- package/lib/task-diff.ts +91 -0
- package/lib/task-env-forwarder.ts +228 -0
- package/lib/task-env-source.ts +249 -0
- package/lib/task-environment/anchor.ts +31 -0
- package/lib/task-environment/apple-container.ts +860 -0
- package/lib/task-environment/docker.ts +1344 -0
- package/lib/task-environment/index.ts +466 -0
- package/lib/task-environment/legacy-adoption.ts +467 -0
- package/lib/task-environment/machine-environment.ts +61 -0
- package/lib/task-environment/machine-task-up.ts +1227 -0
- package/lib/task-environment/machine.ts +974 -0
- package/lib/task-environment/registry.ts +59 -0
- package/lib/task-environment/self-identity.ts +20 -0
- package/lib/task-environment/self.ts +566 -0
- package/lib/task-environment/types.ts +642 -0
- package/lib/task-environment/workspace-files.ts +685 -0
- package/lib/task-environment/workspace-runtime.fixture.ts +50 -0
- package/lib/task-files-machine-retirement-outbox.ts +592 -0
- package/lib/task-files-machine-retirement.ts +116 -0
- package/lib/task-files-readiness.ts +127 -0
- package/lib/task-gateway-port.ts +22 -0
- package/lib/task-gateway-protocol.ts +190 -0
- package/lib/task-gateway-recovery.ts +47 -0
- package/lib/task-gateway-state.ts +512 -0
- package/lib/task-identity.ts +19 -0
- package/lib/task-inventory.ts +682 -0
- package/lib/task-machine-credentials-activation.ts +95 -0
- package/lib/task-machine-swap-safety.ts +729 -0
- package/lib/task-person-credentials.ts +76 -0
- package/lib/task-project-ops-host.ts +147 -0
- package/lib/task-project-ops.ts +1062 -0
- package/lib/task-secret-staging.ts +16 -0
- package/lib/task-status.ts +46 -0
- package/lib/task-token.ts +46 -0
- package/lib/token-broker-adopt.ts +344 -0
- package/lib/token-broker-lambda.ts +105 -0
- package/lib/token-broker-local.ts +392 -0
- package/lib/token-broker-revision.ts +310 -0
- package/lib/token-broker-ssm.ts +525 -0
- package/lib/token-broker.ts +218 -0
- package/lib/token-lanes/openai-oauth.ts +123 -0
- package/lib/token-lanes/registry.ts +45 -0
- package/lib/token-lanes/types.ts +163 -0
- package/lib/token-lanes/xai-oauth.ts +165 -0
- package/lib/transcript.ts +128 -0
- package/lib/tunnel-registry.ts +220 -0
- package/lib/tunnel-runtime.ts +235 -0
- package/lib/ulid.ts +16 -0
- package/lib/user-bus-env.ts +116 -0
- package/lib/user-credentials-store.ts +435 -0
- package/lib/vnode-pressure.ts +142 -0
- package/package.json +115 -8
- package/runner/runner.mjs +442 -0
- package/scripts/agent/_common.sh +633 -0
- package/scripts/agent/env-hook.sh +626 -0
- package/scripts/agent/moondeck-cli.sh +5 -0
- package/scripts/agent/task-down.sh +291 -0
- package/scripts/agent/task-status.sh +70 -0
- package/scripts/agent/task-up.sh +3761 -0
- package/scripts/agent/uai-env-exec.sh +26 -0
- package/scripts/install/darwin.ts +1048 -0
- package/scripts/install/linux.ts +958 -0
- package/scripts/install/types.ts +78 -0
- package/scripts/install/util.ts +254 -0
- package/scripts/install/win.ts +143 -0
- package/src/activation-respawn.ts +155 -0
- package/src/apple-tunnel-route.ts +104 -0
- package/src/capture-wire.ts +102 -0
- package/src/cli.ts +2583 -0
- package/src/cloud-credentials.ts +2144 -0
- package/src/cloud-origins.ts +23 -0
- package/src/credential-location.ts +248 -0
- package/src/credential-wire-validation.ts +25 -0
- package/src/custom-connections.ts +237 -0
- package/src/engine-account-paths.ts +11 -0
- package/src/engine-models.ts +39 -0
- package/src/engine-usage.ts +104 -0
- package/src/event-outbox.ts +185 -0
- package/src/index.ts +2899 -0
- package/src/load-env.ts +97 -0
- package/src/machine-tunnel-route.ts +156 -0
- package/src/main.ts +6349 -0
- package/src/never-proxied-domains.ts +31 -0
- package/src/outbound-frame.ts +31 -0
- package/src/paths.ts +81 -0
- package/src/project-env-latest.ts +130 -0
- package/src/project-env-policy.ts +396 -0
- package/src/project-env-wording.ts +53 -0
- package/src/protocol.ts +5545 -0
- package/src/runtime-bootstrap.ts +165 -0
- package/src/station-identity.ts +96 -0
- package/src/task-env-names.ts +28 -0
- package/src/task-secrets.ts +75 -0
- package/src/tunnel-dial.ts +102 -0
- package/src/ui/server.ts +852 -0
- package/src/ui/types.ts +173 -0
- package/src/workspace-path-policy.ts +41 -0
- package/ui/app.js +1102 -0
- package/ui/index.html +90 -0
- package/ui/style.css +685 -0
- package/ui/uai-favicon.svg +21 -0
- package/ui/uai-logo-black.svg +9 -0
- package/ui/uai-wheel.svg +9 -0
- package/bin/moondeck-station.mjs +0 -3
package/lib/skills.ts
ADDED
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-agent skills materialisation (ADR-046).
|
|
3
|
+
*
|
|
4
|
+
* A task agent's skills (reference links + documents, resolved cloud-side from
|
|
5
|
+
* its persona + team) are written to a per-agent `SKILL.md` inside the task
|
|
6
|
+
* workspace. The workspace is bind-mounted into the container at `/workspace`
|
|
7
|
+
* (ADR-014), so writing on the host makes the file appear in the container —
|
|
8
|
+
* no `docker cp`/`exec` needed (same mechanism as attachments).
|
|
9
|
+
*
|
|
10
|
+
* Each agent gets its OWN file under `.moondeck/agents/<id>/` so agents in a shared
|
|
11
|
+
* container don't see each other's material; the agent's system preamble points
|
|
12
|
+
* at its file (buildSystemPreamble), which is the reliable cross-CLI delivery
|
|
13
|
+
* (no dependency on Claude's project-skill auto-discovery in headless mode).
|
|
14
|
+
*
|
|
15
|
+
* ADR-047 adds a `package` skill type — a native Claude Agent Skill (folder +
|
|
16
|
+
* scripts) installed into the container's Claude skills dir via `docker exec`
|
|
17
|
+
* (git clone or an install command), so Claude discovers it natively. Package
|
|
18
|
+
* skills are Claude-only and task-level (shared by the container's agents),
|
|
19
|
+
* NOT written into the flat per-agent SKILL.md.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { taskWorkspaceDir } from "./env";
|
|
23
|
+
import { dockerCli } from "./docker-exec";
|
|
24
|
+
import {
|
|
25
|
+
taskAppContainerName,
|
|
26
|
+
taskContainerCli,
|
|
27
|
+
} from "./task-container-cli";
|
|
28
|
+
import { runtimeAuthorityDockerExecArgs } from "./runtime-authority";
|
|
29
|
+
import type { RosterAgent, RosterSkill } from "./agents/types";
|
|
30
|
+
import {
|
|
31
|
+
SKILL_FILES_DIR,
|
|
32
|
+
SKILL_FILES_MANIFEST,
|
|
33
|
+
flatSkills,
|
|
34
|
+
isSkillFileName,
|
|
35
|
+
renderSkillLayout,
|
|
36
|
+
skillSlug,
|
|
37
|
+
} from "./skill-layout";
|
|
38
|
+
|
|
39
|
+
export {
|
|
40
|
+
SKILL_FILE_MAX_BYTES,
|
|
41
|
+
SKILL_FILE_MAX_LINES,
|
|
42
|
+
SKILL_FILES_DIR,
|
|
43
|
+
SKILL_INDEX_THRESHOLD_LINES,
|
|
44
|
+
fitsSkillFileBudget,
|
|
45
|
+
hasSkillIndex,
|
|
46
|
+
isSkillFileName,
|
|
47
|
+
renderSkillLayout,
|
|
48
|
+
renderSkillMd,
|
|
49
|
+
skillSlug,
|
|
50
|
+
skillSummary,
|
|
51
|
+
splitSkillDocument,
|
|
52
|
+
type SkillLayout,
|
|
53
|
+
} from "./skill-layout";
|
|
54
|
+
import {
|
|
55
|
+
makeContainedDirectories,
|
|
56
|
+
makeContainedDirectoriesSync,
|
|
57
|
+
readContainedFrom,
|
|
58
|
+
removeContainedEntry,
|
|
59
|
+
removeContainedEntrySync,
|
|
60
|
+
WorkspaceDirectoryMissingError,
|
|
61
|
+
writeContainedFileSync,
|
|
62
|
+
type RuntimeNamespace,
|
|
63
|
+
} from "./task-environment/workspace-files";
|
|
64
|
+
|
|
65
|
+
/** The container path of an agent's SKILL.md — what the preamble points at. */
|
|
66
|
+
export function containerSkillFile(agentId: string): string {
|
|
67
|
+
return `/workspace/.moondeck/agents/${agentId}/SKILL.md`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The files the last split wrote, from its manifest; none when unreadable. */
|
|
71
|
+
function previousSkillFiles(workspace: string, folder: readonly string[]): string[] {
|
|
72
|
+
try {
|
|
73
|
+
const read = readContainedFrom(workspace, [...folder, SKILL_FILES_MANIFEST], 0, 256 * 1024);
|
|
74
|
+
if (!read) return [];
|
|
75
|
+
return read.bytes.toString("utf8").split("\n").filter(isSkillFileName);
|
|
76
|
+
} catch {
|
|
77
|
+
return [];
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Each agent's skills work still in flight through the task's runtime
|
|
83
|
+
* (macOS), by task and agent. A write while one is in flight waits for it,
|
|
84
|
+
* then reads the folder and writes its layout, so no deletion or write of an
|
|
85
|
+
* older layout ever lands after a newer one. On Linux every step is done at
|
|
86
|
+
* once and nothing waits here.
|
|
87
|
+
*/
|
|
88
|
+
const skillsInFlight = new Map<string, Promise<void>>();
|
|
89
|
+
/**
|
|
90
|
+
* Each agent's latest write, so a queued write that a newer one replaced is
|
|
91
|
+
* skipped. Only queued writes read it, so a key is dropped once nothing is
|
|
92
|
+
* queued for it: the runner is long-lived, and tasks and agents come and go.
|
|
93
|
+
*/
|
|
94
|
+
const skillWrites = new Map<string, number>();
|
|
95
|
+
|
|
96
|
+
/** Tests only: how many agents the runner still holds skills state for. */
|
|
97
|
+
export function skillsStateForTests(): { writes: number; inFlight: number } {
|
|
98
|
+
return { writes: skillWrites.size, inFlight: skillsInFlight.size };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Write an agent's SKILL.md into the task workspace (idempotent: overwrites,
|
|
103
|
+
* or removes the file when the agent has no skills). A split skill's files
|
|
104
|
+
* go beside it in `skills/`, written before the index that names them.
|
|
105
|
+
* Files the previous layout wrote and this one does not are removed first;
|
|
106
|
+
* never a file this layout writes. Returns the container path when written
|
|
107
|
+
* (or queued behind runtime work still in flight), else null. Best-effort —
|
|
108
|
+
* a write failure is swallowed so a skills problem never blocks the agent
|
|
109
|
+
* from running.
|
|
110
|
+
*/
|
|
111
|
+
export function writeAgentSkills(
|
|
112
|
+
taskId: string,
|
|
113
|
+
agent: RosterAgent,
|
|
114
|
+
runtime?: () => Promise<RuntimeNamespace | null>,
|
|
115
|
+
): string | null {
|
|
116
|
+
const key = `${taskId}\0${agent.id}`;
|
|
117
|
+
const generation = (skillWrites.get(key) ?? 0) + 1;
|
|
118
|
+
skillWrites.set(key, generation);
|
|
119
|
+
const result = flatSkills(agent).length === 0 ? null : containerSkillFile(agent.id);
|
|
120
|
+
const inFlight = skillsInFlight.get(key);
|
|
121
|
+
if (inFlight) {
|
|
122
|
+
// Behind the runtime work of an earlier write; only the latest queued one runs.
|
|
123
|
+
track(key, inFlight.then(() => (skillWrites.get(key) === generation ? writeSkillsNow(taskId, agent, runtime)?.done : undefined)));
|
|
124
|
+
return result;
|
|
125
|
+
}
|
|
126
|
+
const attempt = writeSkillsNow(taskId, agent, runtime);
|
|
127
|
+
if (attempt?.done) {
|
|
128
|
+
track(key, attempt.done);
|
|
129
|
+
} else {
|
|
130
|
+
// Done at once (or refused), with nothing queued: no state to keep.
|
|
131
|
+
skillWrites.delete(key);
|
|
132
|
+
}
|
|
133
|
+
return attempt === null ? null : attempt.path;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Hold `work` as the agent's in-flight skills work until it settles. When
|
|
138
|
+
* it is still the latest (nothing newer queued behind it), the agent's
|
|
139
|
+
* state goes with it, however the work ended.
|
|
140
|
+
*/
|
|
141
|
+
function track(key: string, work: Promise<void>): void {
|
|
142
|
+
const settled = work.catch(() => {}).finally(() => {
|
|
143
|
+
if (skillsInFlight.get(key) !== settled) return;
|
|
144
|
+
skillsInFlight.delete(key);
|
|
145
|
+
skillWrites.delete(key);
|
|
146
|
+
});
|
|
147
|
+
skillsInFlight.set(key, settled);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* One write, done at once where the host can. What only the runtime can do
|
|
152
|
+
* is in `done`, in order: removals first, then writes. Null when refused.
|
|
153
|
+
*/
|
|
154
|
+
function writeSkillsNow(
|
|
155
|
+
taskId: string,
|
|
156
|
+
agent: RosterAgent,
|
|
157
|
+
runtime?: () => Promise<RuntimeNamespace | null>,
|
|
158
|
+
): { path: string | null; done?: Promise<void> } | null {
|
|
159
|
+
// Into the task's own .moondeck, never through a link the task put there
|
|
160
|
+
// (#cb3j0z). Where only the task's runtime can make the agent's directory
|
|
161
|
+
// (macOS), it is made there once the runtime is reachable, then the file
|
|
162
|
+
// written; the agent reads it when it consults its skills.
|
|
163
|
+
const workspace = taskWorkspaceDir(taskId);
|
|
164
|
+
const directory = [".moondeck", "agents", agent.id];
|
|
165
|
+
const file = [...directory, "SKILL.md"];
|
|
166
|
+
const folder = [...directory, SKILL_FILES_DIR];
|
|
167
|
+
const later: ((reached: RuntimeNamespace) => Promise<void>)[] = [];
|
|
168
|
+
/**
|
|
169
|
+
* Remove entries now where the host can; the rest through the runtime,
|
|
170
|
+
* in order, never a skills file the layout being written names (`keep`).
|
|
171
|
+
*/
|
|
172
|
+
const remove = (entries: string[][], keep: ReadonlySet<string>) => {
|
|
173
|
+
const left: string[][] = [];
|
|
174
|
+
for (const entry of entries) {
|
|
175
|
+
try {
|
|
176
|
+
removeContainedEntrySync(workspace, entry);
|
|
177
|
+
} catch {
|
|
178
|
+
left.push(entry);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
if (left.length === 0) return;
|
|
182
|
+
later.push(async (reached) => {
|
|
183
|
+
for (const entry of left) {
|
|
184
|
+
const inFolder = entry.length === folder.length + 1 && entry.slice(0, -1).join("/") === folder.join("/");
|
|
185
|
+
if (inFolder && keep.has(entry[entry.length - 1]!)) continue;
|
|
186
|
+
await removeContainedEntry(workspace, entry, reached);
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
};
|
|
190
|
+
const deferred = (): Promise<void> | undefined => {
|
|
191
|
+
if (later.length === 0 || !runtime) return undefined;
|
|
192
|
+
return (async () => {
|
|
193
|
+
const reached = await runtime();
|
|
194
|
+
if (!reached) return;
|
|
195
|
+
for (const step of later) await step(reached);
|
|
196
|
+
})();
|
|
197
|
+
};
|
|
198
|
+
try {
|
|
199
|
+
const previous = previousSkillFiles(workspace, folder);
|
|
200
|
+
const previousEntries = previous.length
|
|
201
|
+
? [...previous.map((name) => [...folder, name]), [...folder, SKILL_FILES_MANIFEST]]
|
|
202
|
+
: [];
|
|
203
|
+
if (flatSkills(agent).length === 0) {
|
|
204
|
+
remove([...previousEntries, file], new Set());
|
|
205
|
+
return { path: null, done: deferred() };
|
|
206
|
+
}
|
|
207
|
+
const { skillMd, files } = renderSkillLayout(agent);
|
|
208
|
+
const names = files.map((f) => f.name);
|
|
209
|
+
if (names.some((name) => !isSkillFileName(name))) return null;
|
|
210
|
+
const keep = new Set(names);
|
|
211
|
+
const writes: { parts: string[]; bytes: Uint8Array }[] = [
|
|
212
|
+
...files.map((f) => ({ parts: [...folder, f.name], bytes: Buffer.from(f.content) })),
|
|
213
|
+
...(files.length ? [{ parts: [...folder, SKILL_FILES_MANIFEST], bytes: Buffer.from(`${names.join("\n")}\n`) }] : []),
|
|
214
|
+
{ parts: file, bytes: Buffer.from(skillMd) },
|
|
215
|
+
];
|
|
216
|
+
// Stale: what the previous layout wrote and this one does not, removed
|
|
217
|
+
// before the new manifest is written. A flat layout keeps none.
|
|
218
|
+
remove(
|
|
219
|
+
files.length
|
|
220
|
+
? previous.filter((name) => !keep.has(name)).map((name) => [...folder, name])
|
|
221
|
+
: previousEntries,
|
|
222
|
+
keep,
|
|
223
|
+
);
|
|
224
|
+
const directories = files.length ? [directory, folder] : [directory];
|
|
225
|
+
const writeAll = () => {
|
|
226
|
+
for (const write of writes) writeContainedFileSync(workspace, write.parts, write.bytes);
|
|
227
|
+
};
|
|
228
|
+
if (!directories.every((d) => makeContainedDirectoriesSync(workspace, d))) {
|
|
229
|
+
let written = false;
|
|
230
|
+
try {
|
|
231
|
+
writeAll();
|
|
232
|
+
written = true;
|
|
233
|
+
} catch (error) {
|
|
234
|
+
if (!(error instanceof WorkspaceDirectoryMissingError) || !runtime) throw error;
|
|
235
|
+
}
|
|
236
|
+
if (!written) {
|
|
237
|
+
later.push(async (reached) => {
|
|
238
|
+
for (const d of directories) await makeContainedDirectories(workspace, d, reached);
|
|
239
|
+
writeAll();
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
return { path: containerSkillFile(agent.id), done: deferred() };
|
|
243
|
+
}
|
|
244
|
+
writeAll();
|
|
245
|
+
return { path: containerSkillFile(agent.id), done: deferred() };
|
|
246
|
+
} catch {
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ---------------------------------------------------------------------------
|
|
252
|
+
// ADR-047 package skills — native Claude Agent Skills installed in-container.
|
|
253
|
+
// ---------------------------------------------------------------------------
|
|
254
|
+
|
|
255
|
+
const SKILL_EXEC_TIMEOUT_MS = 180_000; // clones / npx installs can be slow
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* In-container git-skill installer. Shallow-clones a repo, then works out where
|
|
259
|
+
* the actual skill(s) live and copies each into `~/.claude/skills/<name>/`:
|
|
260
|
+
*
|
|
261
|
+
* - `$2` (subpath) given → that folder is the skill.
|
|
262
|
+
* - else repo root has a SKILL.md → the whole repo is the skill.
|
|
263
|
+
* - else search the clone for SKILL.md files → install every skill folder it
|
|
264
|
+
* finds (a "collection" repo), each under its own folder name.
|
|
265
|
+
* - else exit 3 → the repo isn't a ready skill (needs a build; use cli mode).
|
|
266
|
+
*
|
|
267
|
+
* Params are positional argv ($1=url, $2=subpath, $3=name) — never interpolated
|
|
268
|
+
* into this text — so a repo URL / subpath can't shell-inject. `.git` is
|
|
269
|
+
* stripped and the temp clone removed; existing skill dirs are left untouched.
|
|
270
|
+
*/
|
|
271
|
+
const GIT_INSTALL_SCRIPT = `
|
|
272
|
+
set -eu
|
|
273
|
+
url="$1"; sub="$2"; name="$3"
|
|
274
|
+
skills="$HOME/.claude/skills"
|
|
275
|
+
mkdir -p "$skills"
|
|
276
|
+
tmp="$(mktemp -d)"
|
|
277
|
+
trap 'rm -rf "$tmp"' EXIT
|
|
278
|
+
git clone --depth 1 --quiet "$url" "$tmp/repo"
|
|
279
|
+
repo="$tmp/repo"
|
|
280
|
+
install_one() {
|
|
281
|
+
src="$1"; dest="$skills/$2"
|
|
282
|
+
if [ -e "$dest" ]; then echo "skip $2"; return 0; fi
|
|
283
|
+
if [ ! -f "$src/SKILL.md" ]; then echo "no-skill-md: $src"; return 1; fi
|
|
284
|
+
rm -rf "$src/.git"
|
|
285
|
+
cp -R "$src" "$dest"
|
|
286
|
+
echo "installed $2"
|
|
287
|
+
}
|
|
288
|
+
if [ -n "$sub" ]; then
|
|
289
|
+
install_one "$repo/$sub" "$name"
|
|
290
|
+
elif [ -f "$repo/SKILL.md" ]; then
|
|
291
|
+
install_one "$repo" "$name"
|
|
292
|
+
else
|
|
293
|
+
n=0
|
|
294
|
+
while IFS= read -r -d '' md; do
|
|
295
|
+
d="$(dirname "$md")"
|
|
296
|
+
if install_one "$d" "$(basename "$d")"; then n=$((n+1)); fi
|
|
297
|
+
done < <(find "$repo" -maxdepth 4 -name SKILL.md -not -path '*/.git/*' -print0)
|
|
298
|
+
if [ "$n" -eq 0 ]; then echo "no-skills-found"; exit 3; fi
|
|
299
|
+
fi
|
|
300
|
+
`;
|
|
301
|
+
|
|
302
|
+
/** A short label for an installed package skill (for the preamble note). */
|
|
303
|
+
export function installedSkillPath(s: RosterSkill): string {
|
|
304
|
+
return s.source === "git"
|
|
305
|
+
? `~/.claude/skills/${skillSlug(s.name)}/`
|
|
306
|
+
: `${s.name} (install command)`;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Package skills across the Claude agents in a roster, deduped by folder slug.
|
|
311
|
+
* Package skills are a Claude Code capability (ADR-047), so Codex agents are
|
|
312
|
+
* skipped; the install is task-level (one container, shared skills dir).
|
|
313
|
+
*/
|
|
314
|
+
export function collectPackageSkills(agents: RosterAgent[]): RosterSkill[] {
|
|
315
|
+
const seen = new Set<string>();
|
|
316
|
+
const out: RosterSkill[] = [];
|
|
317
|
+
for (const a of agents) {
|
|
318
|
+
if (a.kind !== "claude") continue;
|
|
319
|
+
for (const s of a.skills ?? []) {
|
|
320
|
+
if (s.type !== "package" || !s.value) continue;
|
|
321
|
+
const key = skillSlug(s.name);
|
|
322
|
+
if (seen.has(key)) continue;
|
|
323
|
+
seen.add(key);
|
|
324
|
+
out.push(s);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return out;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Injectable docker-exec seam (mocked in tests). Runs `cmd` in `container` as
|
|
331
|
+
* the `node` user, optionally with a working dir. Values are argv, never a
|
|
332
|
+
* shell string — a URL/subpath can't shell-inject (cli mode is the exception:
|
|
333
|
+
* it runs the author's command via `bash -lc` by design). */
|
|
334
|
+
export type SkillExec = (
|
|
335
|
+
container: string,
|
|
336
|
+
cmd: string[],
|
|
337
|
+
cwd?: string,
|
|
338
|
+
) => Promise<{ status: number | null; stderr: string }>;
|
|
339
|
+
|
|
340
|
+
export function packageSkillDockerExecArgs(
|
|
341
|
+
container: string,
|
|
342
|
+
cmd: string[],
|
|
343
|
+
cwd?: string,
|
|
344
|
+
): string[] {
|
|
345
|
+
return [
|
|
346
|
+
"exec",
|
|
347
|
+
"-u",
|
|
348
|
+
"node",
|
|
349
|
+
...(cwd ? ["-w", cwd] : []),
|
|
350
|
+
...runtimeAuthorityDockerExecArgs(),
|
|
351
|
+
container,
|
|
352
|
+
...cmd,
|
|
353
|
+
];
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const dockerExec: SkillExec = async (container, cmd, cwd) => {
|
|
357
|
+
const full = packageSkillDockerExecArgs(container, cmd, cwd);
|
|
358
|
+
const res = await taskContainerCli(full, { timeoutMs: SKILL_EXEC_TIMEOUT_MS });
|
|
359
|
+
return { status: res.status, stderr: res.stderr };
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* A `skills add …` install command (github.com/vercel-labs/skills, the CLI
|
|
364
|
+
* behind the skills marketplace) asks which agents to install for and for a
|
|
365
|
+
* confirmation unless an agent is detected or `-y`/`--agent` are given. The
|
|
366
|
+
* install runs at task-up with no TTY and, on a fresh machine, before any
|
|
367
|
+
* agent has left its footprint, so the CLI refused: "Interactive prompt
|
|
368
|
+
* required but stdin is not a TTY … Use --agent <name> and -y" (every task
|
|
369
|
+
* start since the Marketing persona gained the skill; live 2026-09-10).
|
|
370
|
+
* Insert the two flags right after `skills add` when the author left them
|
|
371
|
+
* out — after the subcommand, so a trailing `&& …` or a leading `cd …`
|
|
372
|
+
* keeps working. Any other install command is returned as written.
|
|
373
|
+
*/
|
|
374
|
+
export function nonInteractiveInstallCommand(command: string): string {
|
|
375
|
+
const add = /\bskills\s+add\b/;
|
|
376
|
+
const found = add.exec(command);
|
|
377
|
+
if (!found) return command;
|
|
378
|
+
// Only the flags AFTER the subcommand count: `npx -y skills add …` is npx's
|
|
379
|
+
// own -y, not the CLI's. And the CLI wants its options AFTER the source —
|
|
380
|
+
// put before it, the parser reports "Missing required argument: source"
|
|
381
|
+
// (live 2026-09-10, smoke 14) — so the flags go right after the source
|
|
382
|
+
// token: the first token past `add` that is not an option or an option's
|
|
383
|
+
// value, stopping at a shell operator.
|
|
384
|
+
const afterAdd = found.index + found[0].length;
|
|
385
|
+
const rest = command.slice(afterAdd);
|
|
386
|
+
const tail = rest.split(/(?=\s(?:&&|\|\||[|;]))/)[0] ?? rest;
|
|
387
|
+
const flags: string[] = [];
|
|
388
|
+
if (!/(^|\s)(-y|--yes)(\s|$)/.test(tail)) flags.push("-y");
|
|
389
|
+
if (!/(^|\s)(-a|--agent)(\s|=)/.test(tail)) flags.push("--agent claude-code");
|
|
390
|
+
if (flags.length === 0) return command;
|
|
391
|
+
const valued = /^(-a|--agent|-s|--skill)$/;
|
|
392
|
+
const tokens = [...tail.matchAll(/\S+/g)];
|
|
393
|
+
let sourceEnd = -1;
|
|
394
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
395
|
+
const token = tokens[i]![0];
|
|
396
|
+
if (token.startsWith("-")) {
|
|
397
|
+
if (valued.test(token)) i += 1; // skip the option's value
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
sourceEnd = tokens[i]!.index! + token.length;
|
|
401
|
+
break;
|
|
402
|
+
}
|
|
403
|
+
const at = sourceEnd >= 0 ? afterAdd + sourceEnd : afterAdd + tail.length;
|
|
404
|
+
return `${command.slice(0, at)} ${flags.join(" ")}${command.slice(at)}`;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Install a roster's package skills into the task container (ADR-047). Runs at
|
|
409
|
+
* task-up (after the container is running), mirroring the git-identity/github
|
|
410
|
+
* `docker exec` setup: best-effort, idempotent, never blocks the agents.
|
|
411
|
+
*
|
|
412
|
+
* Returns the human-readable paths of skills that are now present (for the
|
|
413
|
+
* agents' preamble note). `exec` is injectable for tests.
|
|
414
|
+
*/
|
|
415
|
+
export async function installPackageSkills(
|
|
416
|
+
taskId: string,
|
|
417
|
+
agents: RosterAgent[],
|
|
418
|
+
exec: SkillExec = dockerExec,
|
|
419
|
+
): Promise<string[]> {
|
|
420
|
+
const pkgs = collectPackageSkills(agents);
|
|
421
|
+
if (pkgs.length === 0) return [];
|
|
422
|
+
const container = taskAppContainerName(taskId);
|
|
423
|
+
const done: string[] = [];
|
|
424
|
+
|
|
425
|
+
for (const s of pkgs) {
|
|
426
|
+
const slug = skillSlug(s.name);
|
|
427
|
+
try {
|
|
428
|
+
if (s.source === "git") {
|
|
429
|
+
// Idempotent across ensureSessions re-runs: a marker records that the
|
|
430
|
+
// clone+install already ran, so reconnects don't re-clone.
|
|
431
|
+
const marker = `/home/node/.claude/skills/.uai-git-${slug}.done`;
|
|
432
|
+
if ((await exec(container, ["test", "-e", marker])).status === 0) {
|
|
433
|
+
done.push(installedSkillPath(s));
|
|
434
|
+
continue;
|
|
435
|
+
}
|
|
436
|
+
// subpath is de-fanged against traversal; url/subpath/name reach the
|
|
437
|
+
// installer as positional argv (never interpolated) — no shell inject.
|
|
438
|
+
const sub = (s.subpath ?? "").replace(/^[/.]+/, "").replace(/\.\.+/g, "");
|
|
439
|
+
const r = await exec(container, [
|
|
440
|
+
"bash",
|
|
441
|
+
"-lc",
|
|
442
|
+
GIT_INSTALL_SCRIPT,
|
|
443
|
+
"uai-skill", // $0
|
|
444
|
+
s.value, // $1 url
|
|
445
|
+
sub, // $2 subpath
|
|
446
|
+
slug, // $3 name
|
|
447
|
+
]);
|
|
448
|
+
if (r.status !== 0) {
|
|
449
|
+
const why =
|
|
450
|
+
r.status === 3
|
|
451
|
+
? "no SKILL.md found — the repo may need a build (try cli mode)"
|
|
452
|
+
: r.stderr.trim();
|
|
453
|
+
console.warn(`[skills] ${taskId}: git skill ${s.name} failed: ${why}`);
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
await exec(container, ["mkdir", "-p", "/home/node/.claude/skills"]);
|
|
457
|
+
await exec(container, ["touch", marker]);
|
|
458
|
+
console.log(`[skills] ${taskId}: installed git skill ${s.name}`);
|
|
459
|
+
done.push(installedSkillPath(s));
|
|
460
|
+
} else if (s.source === "cli") {
|
|
461
|
+
// Idempotent across ensureSessions re-runs (reconnects): a marker file
|
|
462
|
+
// records that the command already ran, since a CLI install isn't
|
|
463
|
+
// self-idempotent like `git clone` into an existing dir.
|
|
464
|
+
const marker = `/home/node/.claude/skills/.uai-cli-${slug}.done`;
|
|
465
|
+
if ((await exec(container, ["test", "-e", marker])).status === 0) {
|
|
466
|
+
done.push(installedSkillPath(s));
|
|
467
|
+
continue;
|
|
468
|
+
}
|
|
469
|
+
// The author's CLI drops files into .claude/skills; run it in the repo.
|
|
470
|
+
const c = await exec(
|
|
471
|
+
container,
|
|
472
|
+
["bash", "-lc", nonInteractiveInstallCommand(s.value)],
|
|
473
|
+
"/workspace",
|
|
474
|
+
);
|
|
475
|
+
if (c.status !== 0) {
|
|
476
|
+
console.warn(`[skills] ${taskId}: install cmd for ${s.name} failed: ${c.stderr.trim()}`);
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
479
|
+
await exec(container, ["mkdir", "-p", "/home/node/.claude/skills"]);
|
|
480
|
+
await exec(container, ["touch", marker]);
|
|
481
|
+
console.log(`[skills] ${taskId}: ran install command for ${s.name}`);
|
|
482
|
+
done.push(installedSkillPath(s));
|
|
483
|
+
}
|
|
484
|
+
} catch (e) {
|
|
485
|
+
console.warn(`[skills] ${taskId}: package skill ${s.name} errored: ${String(e)}`);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
return done;
|
|
489
|
+
}
|