@cognisphere-sh/cognisphere-harness 0.12.0 → 0.13.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 (38) hide show
  1. package/CHANGELOG.md +66 -11
  2. package/LICENSE +21 -0
  3. package/dist-web/assets/{file-editor-CQSmlsy_.js → file-editor-CqGYKmT3.js} +1 -1
  4. package/dist-web/assets/{index-1Hg12QW0.js → index-CDE-jsH8.js} +3 -3
  5. package/dist-web/index.html +1 -1
  6. package/home-template/config.example +0 -6
  7. package/home-template/docs/base-harness/README.md +11 -4
  8. package/home-template/scripts/build.sh +0 -5
  9. package/home-template/scripts/lib/remote-bootstrap.sh +2 -6
  10. package/home-template/scripts/setup-server.sh +7 -39
  11. package/package.json +7 -5
  12. package/skills/cognisphere-upgrade/SKILL.md +1 -1
  13. package/skills/create-plugin/SKILL.md +2 -2
  14. package/src/agents/base-agent/bootstrap/bootstrap.sh +2 -18
  15. package/src/agents/base-agent/extensions/context-meta.ts +4 -1
  16. package/src/agents/base-agent/system_prompts/0-base_prompt.md +3 -0
  17. package/src/api/agents.ts +3 -13
  18. package/src/api/credentials.ts +0 -15
  19. package/src/api/models.ts +2 -8
  20. package/src/api/secrets.ts +3 -3
  21. package/src/cli/index.ts +1 -1
  22. package/src/cli/init.ts +0 -12
  23. package/src/core/agent-manager.ts +12 -47
  24. package/src/core/config.ts +6 -0
  25. package/src/core/main.ts +10 -2
  26. package/src/core/model-access.ts +55 -0
  27. package/src/core/models-catalog.ts +72 -40
  28. package/src/core/models-store.ts +3 -4
  29. package/src/core/oauth-logins.ts +7 -3
  30. package/src/core/pi-delivery-receipts.ts +69 -0
  31. package/src/core/pi-models-sync.ts +2 -2
  32. package/src/core/queue.ts +7 -25
  33. package/src/core/rpc.ts +55 -49
  34. package/src/core/runner.ts +137 -131
  35. package/src/core/secrets.ts +2 -3
  36. package/src/core/types.ts +1 -1
  37. package/src/plugins/artifacts/index.ts +1 -1
  38. package/src/agents/base-agent/extensions/harness-bridge.ts +0 -63
@@ -16,7 +16,7 @@
16
16
  } catch (_) {}
17
17
  })();
18
18
  </script>
19
- <script type="module" crossorigin src="/assets/index-1Hg12QW0.js"></script>
19
+ <script type="module" crossorigin src="/assets/index-CDE-jsH8.js"></script>
20
20
  <link rel="modulepreload" crossorigin href="/assets/react-BIlrhC09.js">
21
21
  <link rel="modulepreload" crossorigin href="/assets/radix-DHW5y7jr.js">
22
22
  <link rel="modulepreload" crossorigin href="/assets/markdown-sumxz7Eq.js">
@@ -44,12 +44,6 @@ BACKUP_S3_ACCESS_KEY= # keys for that endpoint (blank = aws CLI chain / IAM r
44
44
  BACKUP_S3_SECRET_KEY=
45
45
  AWS_PROFILE= # local testing only: named aws-cli profile (EC2 uses the IAM role)
46
46
 
47
- # Token for the private @cognisphere-sh/* packages on GitHub Packages.
48
- # Leave blank to use the run user's `gh` CLI token (it must have the
49
- # `read:packages` scope: gh auth refresh -s read:packages). Or paste a
50
- # GitHub PAT with read:packages here for a non-interactive box.
51
- COGNISPHERE_NPM_TOKEN=
52
-
53
47
  # ---- deployment-specific params ---------------------------------------------
54
48
  # This file is harness-owned (refreshed on upgrade). Your app's extra keys go
55
49
  # in `config` too, but document them in scripts/app/config.example and read
@@ -95,8 +95,9 @@ plus an optional `X-App-User` header (see `app/README.md`).
95
95
  agent (`agent/` scope is yours; `<plugin-id>/` scopes are seeded by
96
96
  plugins and overwritten on every start).
97
97
  - `bootstrap/bootstrap.sh` — runs on every agent start; provisions the
98
- agent's `.venv`, system deps, and the pinned `pi` version (installs or
99
- upgrades/downgrades the global `pi` to match; override with `PI_VERSION`).
98
+ agent's `.venv`, system deps and `agent-browser`. Pi itself isn't installed
99
+ here: the harness runs the `pi` bundled with its own package, at the version
100
+ it was tested with.
100
101
 
101
102
  A **thread** is one conversation. `threadIdStrategy` controls how inbound
102
103
  messages map to threads (one global thread, one per plugin, or one per
@@ -142,8 +143,14 @@ Both under `harness/.secrets/` (0600, gitignored, editable via the console):
142
143
  - `secrets.json` — per-agent buckets: `agent` (agent-level env) plus one per
143
144
  plugin id. All keys are injected into the agent's environment.
144
145
  - `models.json` — per-provider credentials + the allowlist of enabled model
145
- ids. Subscription OAuth logins (Claude Pro/Max, Codex) are connected from
146
- the console's Models page instead of pasting keys.
146
+ ids. Subscription OAuth logins (Claude Pro/Max, Sign in with ChatGPT on
147
+ OpenAI, and the legacy OpenAI Codex) are connected from
148
+ the console's Models page instead of pasting keys. Their tokens live in
149
+ `.secrets/pi/` (this harness's own Pi config folder, not `~/.pi/agent`).
150
+ A model is usable when
151
+ its provider has its keys or a connected sign-in and the model is enabled.
152
+ A thread's own model (set from the chat header) that stops being usable
153
+ falls back to the agent's model until it is usable again.
147
154
  - `gws/` — Google sign-in for agents with the **gws** plugin. The shared
148
155
  "Web application" OAuth client (from your GCP project, with
149
156
  `<console origin>/api/gws/oauth/callback` as a redirect URI) is saved
@@ -13,11 +13,6 @@ set -euo pipefail
13
13
  ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
14
14
  [[ -f "$ROOT/config" ]] && source "$ROOT/config"
15
15
 
16
- # @cognisphere-sh/* need a read:packages token even though they're public.
17
- # ~/.npmrc references ${COGNISPHERE_NPM_TOKEN} (written by setup-server.sh);
18
- # export it from config, falling back to the gh CLI token.
19
- export COGNISPHERE_NPM_TOKEN="${COGNISPHERE_NPM_TOKEN:-$(gh auth token 2>/dev/null || true)}"
20
-
21
16
  cd "$ROOT"
22
17
  pnpm install --frozen-lockfile
23
18
  if [[ -f "$ROOT/app/package.json" ]]; then
@@ -58,12 +58,8 @@ command -v claude >/dev/null || curl -fsSL https://claude.ai/install.sh | bash
58
58
  grep -q '\.local/bin' ~/.bashrc || echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc
59
59
  export PATH="$HOME/.local/bin:$PATH"
60
60
 
61
- # gh interactive auth — read:packages is required to install @cognisphere-sh packages
62
- if ! gh auth status >/dev/null 2>&1; then
63
- gh auth login -s read:packages
64
- elif ! gh auth status 2>&1 | grep -q read:packages; then
65
- gh auth refresh -s read:packages
66
- fi
61
+ # gh interactive auth — used to clone the repo below
62
+ gh auth status >/dev/null 2>&1 || gh auth login
67
63
 
68
64
  # Claude Code auth only — setup-token runs the OAuth flow and stores the
69
65
  # credential without starting a session. Skipped if already authenticated.
@@ -50,33 +50,7 @@ npm install -g corepack@latest
50
50
  corepack enable
51
51
  corepack prepare pnpm@latest --activate
52
52
 
53
- # ---- GitHub Packages token --------------------------------------------------
54
- # @cognisphere-sh/* live on GitHub Packages, whose npm registry requires a token
55
- # with the read:packages scope EVEN for public packages (anonymous = 401, a
56
- # scope-short token = 403). Prefer COGNISPHERE_NPM_TOKEN from config (a PAT); else
57
- # use the run user's gh CLI token. A gh OAuth token (gho_*) exists but often lacks
58
- # read:packages, so a bare `gh auth token` check would pass yet still 403 at fetch
59
- # time — verify/repair the scope here instead of failing mid-build.
60
- if [[ -z "${COGNISPHERE_NPM_TOKEN:-}" ]]; then
61
- command -v gh >/dev/null || { echo "no COGNISPHERE_NPM_TOKEN in config and gh CLI not installed. Set COGNISPHERE_NPM_TOKEN (a PAT with read:packages) in config, or install gh and run: sudo -u $RUN_USER gh auth login -s read:packages"; exit 1; }
62
- # gh runs as the app user; ensure it's logged in and the token carries read:packages.
63
- # (gh prints "Token scopes:" to stderr, hence 2>&1.)
64
- if ! sudo -u "$RUN_USER" gh auth status >/dev/null 2>&1; then
65
- echo ">> gh is not authenticated for $RUN_USER — launching login (needs read:packages)"
66
- sudo -u "$RUN_USER" gh auth login -s read:packages || { echo "gh auth login failed"; exit 1; }
67
- elif ! sudo -u "$RUN_USER" gh auth status 2>&1 | grep -q 'read:packages'; then
68
- echo ">> gh token for $RUN_USER lacks the read:packages scope — refreshing"
69
- sudo -u "$RUN_USER" gh auth refresh -s read:packages || { echo "gh auth refresh failed"; exit 1; }
70
- fi
71
- COGNISPHERE_NPM_TOKEN="$(sudo -u "$RUN_USER" gh auth token 2>/dev/null || true)"
72
- fi
73
- [[ -n "$COGNISPHERE_NPM_TOKEN" ]] || { echo "no GitHub token for @cognisphere-sh packages. Set COGNISPHERE_NPM_TOKEN in config (a PAT with read:packages), or run: sudo -u $RUN_USER gh auth refresh -s read:packages"; exit 1; }
74
- # pnpm refuses to expand env-var credentials from the committed project .npmrc,
75
- # so the auth line must live in the run user's ~/.npmrc (a trusted source).
76
53
  RUN_HOME="$(getent passwd "$RUN_USER" | cut -d: -f6)"
77
- sudo -u "$RUN_USER" touch "$RUN_HOME/.npmrc"
78
- sudo -u "$RUN_USER" grep -qF 'npm.pkg.github.com/:_authToken' "$RUN_HOME/.npmrc" \
79
- || echo '//npm.pkg.github.com/:_authToken=${COGNISPHERE_NPM_TOKEN}' | sudo -u "$RUN_USER" tee -a "$RUN_HOME/.npmrc" >/dev/null
80
54
 
81
55
  # ---- credentials ------------------------------------------------------------
82
56
  # `scripts/server.sh secrets` is the single source: it writes users.json
@@ -89,12 +63,13 @@ sudo -u "$RUN_USER" bash "$ROOT/scripts/server.sh" secrets
89
63
  # ---- build (as the app user so node_modules aren't root-owned) -------------
90
64
  # Delegated to scripts/build.sh — the same command used for every later deploy
91
65
  # (git pull && sudo ./scripts/server.sh restart).
92
- sudo -u "$RUN_USER" env COGNISPHERE_NPM_TOKEN="$COGNISPHERE_NPM_TOKEN" bash "$ROOT/scripts/build.sh"
66
+ sudo -u "$RUN_USER" bash "$ROOT/scripts/build.sh"
93
67
 
94
68
  # ---- agent runtimes: build each agent's Python venv + CLIs ------------------
95
69
  # Each agent has a bootstrap/bootstrap.sh (idempotent) that creates <agent>/.venv
96
- # with markitdown/ddgs and installs the pi / agent-browser CLIs the harness
97
- # spawns by name. Run as the app user so the venv and ~/.local installs aren't
70
+ # with markitdown/ddgs and installs the agent-browser CLI agents call by name.
71
+ # (The harness runs the `pi` bundled with its own dependencies, not a global
72
+ # one.) Run as the app user so the venv and ~/.local installs aren't
98
73
  # root-owned. Best-effort: a warning here shouldn't abort the whole provision.
99
74
  for bs in "$ROOT"/harness/agents/*/bootstrap/bootstrap.sh; do
100
75
  [[ -f "$bs" ]] || continue
@@ -114,13 +89,11 @@ NODE_BIN_DIR="$(dirname "$(command -v node)")"
114
89
  # $NODE_BIN_DIR/pnpm.
115
90
  PNPM_BIN="$(command -v pnpm)"
116
91
  PNPM_BIN_DIR="$(dirname "$PNPM_BIN")"
117
- # The harness spawns the `pi` coding agent by name (PATH lookup). pi is a
118
- # per-user install ($RUN_HOME/.local/bin/pi), so that dir MUST be on the unit
119
- # PATH or every spawn fails with `spawn pi ENOENT`. node/pnpm may both live in
92
+ # $RUN_HOME/.local/bin holds per-user tool installs; node/pnpm may both live in
120
93
  # /usr/bin, so don't rely on their dirs to carry it.
121
94
  # $RUN_HOME/.npm-global/bin carries the npm-global CLIs the agent bootstrap
122
- # installs as the app user (pi, agent-browser) when the default npm prefix is
123
- # root-owned. Must be on the unit PATH or bare `agent-browser`/`pi` spawns 127.
95
+ # installs as the app user (agent-browser) when the default npm prefix is
96
+ # root-owned. Must be on the unit PATH or bare `agent-browser` spawns 127.
124
97
  UNIT_PATH="$RUN_HOME/.local/bin:$RUN_HOME/.npm-global/bin:$PNPM_BIN_DIR:$NODE_BIN_DIR:/usr/bin:/bin"
125
98
 
126
99
  # Renaming APP_NAME leaves the old name's units/nginx site/cron behind — clean those up by hand.
@@ -134,10 +107,6 @@ Type=simple
134
107
  User=$RUN_USER
135
108
  WorkingDirectory=$ROOT/harness
136
109
  Environment=PATH=$UNIT_PATH
137
- # Empty so pnpm can expand the \${COGNISPHERE_NPM_TOKEN} ref in the run user's
138
- # ~/.npmrc without warning. The service does no installs, so no real token is
139
- # needed at runtime.
140
- Environment=COGNISPHERE_NPM_TOKEN=
141
110
  ExecStart=$PNPM_BIN exec cognisphere serve --port $HARNESS_PORT
142
111
  Restart=on-failure
143
112
  RestartSec=3
@@ -162,7 +131,6 @@ Environment=PATH=$UNIT_PATH
162
131
  Environment=NODE_ENV=production
163
132
  Environment=PORT=$APP_PORT
164
133
  Environment=HARNESS_URL=http://127.0.0.1:$HARNESS_PORT
165
- Environment=COGNISPHERE_NPM_TOKEN=
166
134
  # 'next start' honors \$PORT; passing "-- -p" trips Next 16's arg parser (it
167
135
  # reads -p as a project dir), so rely on the PORT env above.
168
136
  ExecStart=$PNPM_BIN start
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "@cognisphere-sh/cognisphere-harness",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
+ "license": "MIT",
4
5
  "type": "module",
5
6
  "repository": {
6
7
  "type": "git",
@@ -16,17 +17,18 @@
16
17
  "home-template",
17
18
  "dist-web",
18
19
  "skills",
19
- "CHANGELOG.md"
20
+ "CHANGELOG.md",
21
+ "LICENSE"
20
22
  ],
21
23
  "engines": {
22
- "node": ">=20.12"
24
+ "node": ">=22.19.0"
23
25
  },
24
26
  "publishConfig": {
25
27
  "access": "public"
26
28
  },
27
29
  "dependencies": {
28
- "@earendil-works/pi-ai": "^0.84.4",
29
- "@earendil-works/pi-coding-agent": "^0.84.4",
30
+ "@earendil-works/pi-ai": "0.99.2",
31
+ "@earendil-works/pi-coding-agent": "0.99.2",
30
32
  "@hono/node-server": "^1.13.7",
31
33
  "ajv": "^8.17.1",
32
34
  "better-sqlite3": "^11.7.0",
@@ -9,7 +9,7 @@ metadata:
9
9
 
10
10
  # CogniSphere Upgrade
11
11
 
12
- Drive the **two-phase upgrade** (see `docs/distribution-and-deployment.md` §9):
12
+ Drive the **two-phase upgrade** (see `docs/low-level/cli.md#upgrades`):
13
13
 
14
14
  1. **Code** — bump the installed dependency (`pnpm`/the CLI does this).
15
15
  2. **Data** — *this skill*: edit the harness dir (agents, plugins, secrets) to
@@ -204,8 +204,8 @@ Config/secret edits need an agent restart to take effect:
204
204
 
205
205
  ## Reference
206
206
 
207
- - Full lifecycle + on-disk layout: `docs/server.md` §4.9.3 and §5 (in this
208
- repo, or bundled with the installed package).
207
+ - Plugin lifecycle: `docs/low-level/plugins.md#discovery-and-lifecycle`; on-disk layout:
208
+ `docs/low-level/agents.md#persistent-layout` (repository references).
209
209
  - Builtin plugins are the best examples: `telegram` (long-poll + CLI +
210
210
  multi-kind `EventType`), `gws` (poller + shared lib in seed),
211
211
  `scheduler` (cron + CLI), `agent-messaging` (webhook inbox with
@@ -102,7 +102,7 @@ python -m pip install -r bootstrap/requirements.txt
102
102
  # ── npm global prefix: make it user-writable ──────────────────────────
103
103
  # The default npm prefix may be a root-owned dir (e.g. /usr), so `npm install
104
104
  # -g` fails with EACCES when bootstrap runs as the (non-root) app user. Point
105
- # it at ~/.npm-global — where the pi/agent-browser wrappers already look — and
105
+ # it at ~/.npm-global — where the agent-browser wrapper already looks — and
106
106
  # put its bin dir on PATH for the rest of this script and this shell.
107
107
  if command -v npm >/dev/null 2>&1; then
108
108
  export NPM_CONFIG_PREFIX="$HOME/.npm-global"
@@ -110,23 +110,7 @@ if command -v npm >/dev/null 2>&1; then
110
110
  export PATH="$NPM_CONFIG_PREFIX/bin:$PATH"
111
111
  fi
112
112
 
113
- # ── 3. pi-coding-agent (npm global) ───────────────────────────────────
114
- # Provides the `pi` binary that the harness runner spawns (`pi --mode rpc`).
115
- # Pinned to the version the harness was tested against — keep in sync with
116
- # `@earendil-works/pi-coding-agent` in packages/harness/package.json.
117
- PI_VERSION="${PI_VERSION:-0.84.4}"
118
- installed_pi="$(pi --version 2>/dev/null || true)"
119
- if [ "$installed_pi" = "$PI_VERSION" ]; then
120
- log "pi-coding-agent $PI_VERSION already installed ($(command -v pi))"
121
- elif command -v npm >/dev/null 2>&1; then
122
- log "installing @earendil-works/pi-coding-agent@$PI_VERSION via npm (found: ${installed_pi:-none})..."
123
- npm install -g "@earendil-works/pi-coding-agent@$PI_VERSION" \
124
- || warn "npm install -g @earendil-works/pi-coding-agent@$PI_VERSION failed"
125
- else
126
- warn "npm not found; install Node.js (https://nodejs.org) and re-run, or install @earendil-works/pi-coding-agent@$PI_VERSION by hand"
127
- fi
128
-
129
- # ── 4. agent-browser (npm global) + its Chrome build ──────────────────
113
+ # ── 3. agent-browser (npm global) + its Chrome build ──────────────────
130
114
  if command -v npm >/dev/null 2>&1; then
131
115
  if command -v agent-browser >/dev/null 2>&1; then
132
116
  log "agent-browser already installed ($(command -v agent-browser))"
@@ -171,7 +171,10 @@ export default function contextMeta(pi: ExtensionAPI): void {
171
171
 
172
172
  const last = event.messages[event.messages.length - 1];
173
173
  if (!last) return;
174
- if (last.role === "user") {
174
+ // The last message is usually our own checkpoint (role "custom"): one is
175
+ // placed before every LLM call. Custom messages reach the model as user
176
+ // text, so the block goes there like on a plain user message.
177
+ if (last.role === "user" || last.role === "custom") {
175
178
  if (typeof last.content === "string") last.content = `${last.content}\n\n${block}`;
176
179
  else last.content.push({ type: "text", text: block });
177
180
  } else if (last.role === "toolResult") {
@@ -136,6 +136,7 @@ Timestamp: Fri 2026-04-17 14:30:05 IST
136
136
  Plugin: telegram
137
137
  Channel: 12345
138
138
  ThreadId: telegram-12345
139
+ EventId: 42
139
140
  [IsSilent: true]
140
141
  [Retry: true]
141
142
  [Continuation: true]
@@ -154,6 +155,8 @@ ThreadId: telegram-12345
154
155
  so the result routes back here. It is a harness routing id — distinct from
155
156
  plugin-side ids like a Telegram chat id or Gmail thread id, which appear in
156
157
  the plugin-contributed fields.
158
+ - **EventId** — the harness's id for this message, used to track its delivery.
159
+ You can ignore it.
157
160
  - **IsSilent: true** — appears only on silent messages (background updates).
158
161
  Do not act on a silent message alone; treat it as ambient context.
159
162
  - **Retry: true** — a previous delivery of _this_ message failed or was
package/src/api/agents.ts CHANGED
@@ -18,7 +18,7 @@ import { agentDir, secretsRoot } from "../core/config.js";
18
18
  import type { ServerConfig } from "../core/config.js";
19
19
  import { ModelsStore } from "../core/models-store.js";
20
20
  import { findProviderInCatalog } from "../core/models-catalog.js";
21
- import { requiredCredentialsPresent } from "./credentials.js";
21
+ import { modelUnavailableReason } from "../core/model-access.js";
22
22
 
23
23
  /**
24
24
  * /api/agents/* surface for the web UI.
@@ -284,18 +284,8 @@ export function agentsRouter(am: AgentManager, cfg: ServerConfig): Hono {
284
284
 
285
285
  const entry = findProviderInCatalog(provider);
286
286
  if (!entry) return c.json({ error: `unknown provider "${provider}"` }, 400);
287
- const pcfg = models.getProvider(provider);
288
- const configured =
289
- !!pcfg && requiredCredentialsPresent(entry.credentials, pcfg.credentials);
290
- if (!configured) {
291
- return c.json({ error: `provider "${provider}" is not configured` }, 400);
292
- }
293
- if (!pcfg.enabledModels.includes(modelId)) {
294
- return c.json(
295
- { error: `model "${modelId}" is not enabled for ${provider}` },
296
- 400,
297
- );
298
- }
287
+ const unavailable = modelUnavailableReason(models, provider, modelId);
288
+ if (unavailable) return c.json({ error: unavailable }, 400);
299
289
  if (thinkingLevel !== null && !THINKING_LEVELS.has(thinkingLevel)) {
300
290
  return c.json({ error: "invalid thinkingLevel" }, 400);
301
291
  }
@@ -1,5 +1,3 @@
1
- import type { CredField } from "../core/types.js";
2
-
3
1
  /**
4
2
  * Shared credential masking/merge helpers for /api/models and /api/secrets.
5
3
  *
@@ -27,16 +25,3 @@ export function applyMaskedPut(
27
25
  // unchanged — keep existing value
28
26
  } else if (typeof v === "string") target[key] = v;
29
27
  }
30
-
31
- /** Every required credential field has a non-empty stored value. */
32
- export function requiredCredentialsPresent(
33
- fields: CredField[],
34
- stored: Record<string, string>,
35
- ): boolean {
36
- return fields
37
- .filter((f) => f.required)
38
- .every((f) => {
39
- const v = stored[f.key];
40
- return typeof v === "string" && v.length > 0;
41
- });
42
- }
package/src/api/models.ts CHANGED
@@ -6,6 +6,7 @@ import type { ServerConfig } from "../core/config.js";
6
6
  import { secretsRoot } from "../core/config.js";
7
7
  import type { Logger } from "../core/logger.js";
8
8
  import { PROVIDER_CATALOG } from "../core/models-catalog.js";
9
+ import { providerAuthenticated } from "../core/model-access.js";
9
10
  import { ModelsStore } from "../core/models-store.js";
10
11
  import { OAuthLoginManager } from "../core/oauth-logins.js";
11
12
  import { syncPiModelOverrides } from "../core/pi-models-sync.js";
@@ -14,7 +15,6 @@ import {
14
15
  applyMaskedPut,
15
16
  MASK,
16
17
  maskCredential,
17
- requiredCredentialsPresent,
18
18
  } from "./credentials.js";
19
19
 
20
20
  /**
@@ -124,16 +124,10 @@ export function modelsRouter(
124
124
  for (const field of entry.credentials) {
125
125
  values[field.key] = maskCredential(stored[field.key], field.secret);
126
126
  }
127
- const requiredOk = requiredCredentialsPresent(entry.credentials, stored);
128
127
  const oauthConnected =
129
128
  entry.oauth === true &&
130
129
  readStoredCredential(entry.id)?.type === "oauth";
131
- // OAuth-only providers (no cred fields) are configured iff connected;
132
- // otherwise OAuth connection satisfies missing required fields.
133
- const configured =
134
- entry.credentials.length === 0
135
- ? oauthConnected
136
- : requiredOk || oauthConnected;
130
+ const configured = providerAuthenticated(entry, stored);
137
131
  return {
138
132
  id: entry.id,
139
133
  displayName: entry.displayName,
@@ -26,9 +26,9 @@ import { applyMaskedPut, MASK, maskCredential } from "./credentials.js";
26
26
  * secrets[agentId][bucketId][KEY] = maskedString
27
27
  * schemas[agentId][bucketId] = JsonSchema
28
28
  *
29
- * v0 stores plaintext on disk (HLD §15). Runtime exposes resolved values
30
- * to the pi child as env vars on every spawn, so a value change requires
31
- * a server restart to take effect — the UI surfaces this caveat.
29
+ * Stores plaintext on disk and exposes resolved values to pi as env vars.
30
+ * PUT invalidates the cache and requests a soft reload of affected agents;
31
+ * active batches drain before replacement runners use the new values.
32
32
  */
33
33
 
34
34
  type Bucket = Record<string, string>;
package/src/cli/index.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * `cognisphere` CLI entrypoint — install, scaffold, run, and upgrade a harness
3
- * (see docs/distribution-and-deployment.md §10). Dispatch only; each command
3
+ * (see docs/low-level/cli.md#cli). Dispatch only; each command
4
4
  * lives in its own module.
5
5
  */
6
6
  import { cmdAgentNew } from "./agent.js";
package/src/cli/init.ts CHANGED
@@ -35,14 +35,6 @@ config
35
35
  **/inboxes/
36
36
  `;
37
37
 
38
- // The auth token deliberately is NOT here: pnpm refuses to expand env-var
39
- // credentials from a committed project .npmrc, so the _authToken line must
40
- // live in the run user's ~/.npmrc (scripts/setup-server.sh writes it on the
41
- // server; `init` prints the one-liner for local installs).
42
- const NPMRC = `# @cognisphere-sh/* packages live on GitHub Packages, not npmjs.org.
43
- @cognisphere-sh:registry=https://npm.pkg.github.com
44
- `;
45
-
46
38
  const WORKSPACE_YAML = `packages:
47
39
  - harness
48
40
  - app
@@ -79,7 +71,6 @@ export function cmdInit(argv: string[]): void {
79
71
  description: `${id}: cognisphere app home — the agent harness (harness/) + the user-facing app (app/). Workspace members are listed in pnpm-workspace.yaml.`,
80
72
  });
81
73
  writeFileSync(join(dir, "pnpm-workspace.yaml"), WORKSPACE_YAML);
82
- writeFileSync(join(dir, ".npmrc"), NPMRC);
83
74
  writeFileSync(join(dir, ".gitignore"), GITIGNORE);
84
75
 
85
76
  // Deploy scripts, config templates, and the app/ placeholder — shipped with
@@ -148,9 +139,6 @@ export function cmdInit(argv: string[]): void {
148
139
  info("");
149
140
  info("Next steps:");
150
141
  info(` cd ${cdTarget}`);
151
- info(" # the private registry needs a read:packages token in YOUR ~/.npmrc");
152
- info(" # (same env var the deploy scripts use — export it before installing):");
153
- info(" echo '//npm.pkg.github.com/:_authToken=${COGNISPHERE_NPM_TOKEN}' >> ~/.npmrc");
154
142
  info(" pnpm install");
155
143
  info(" cd harness");
156
144
  info(" pnpm exec cognisphere agent new <name> # add your first agent");
@@ -1,7 +1,6 @@
1
1
  import { chmodSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
2
2
  import { spawn } from "node:child_process";
3
3
  import { join } from "node:path";
4
- import { readStoredCredential } from "@earendil-works/pi-coding-agent";
5
4
  import { Ajv, type ErrorObject } from "ajv";
6
5
  import type { ServerConfig } from "./config.js";
7
6
  import { agentDir, agentsRoot, secretsRoot } from "./config.js";
@@ -12,6 +11,7 @@ import { CORE_PLUGIN_IDS } from "./plugin-registry.js";
12
11
  import { AgentDb } from "./queue.js";
13
12
  import { AgentRunner } from "./runner.js";
14
13
  import { findProviderInCatalog } from "./models-catalog.js";
14
+ import { modelUnavailableReason } from "./model-access.js";
15
15
  import { ModelsStore } from "./models-store.js";
16
16
  import { syncPiModelOverrides } from "./pi-models-sync.js";
17
17
  import { AGENT_BUCKET, SecretsStore } from "./secrets.js";
@@ -70,10 +70,9 @@ export class LifecycleError extends Error {
70
70
  }
71
71
 
72
72
  /**
73
- * Loads agents from disk on boot and exposes them by id. Authoring (creating
74
- * agents, installing plugins, editing configs) is deferred to a later phase
75
- * — see `docs/v0-deferred.md`. v0: agents are created manually on disk; the
76
- * server picks them up at boot.
73
+ * Loads agents from disk on boot and exposes them by id. The CLI scaffolds
74
+ * agent directories; the server discovers them at boot. Settings APIs reload
75
+ * existing agents and plugins. See docs/low-level/core.md for operating procedures.
77
76
  *
78
77
  * Lifecycle:
79
78
  * - boot() → load every agent dir; failed agents stay listed.
@@ -491,6 +490,8 @@ export class AgentManager {
491
490
  envSecrets,
492
491
  resolveProviderEnv: (providerId) =>
493
492
  resolveProviderEnv(this.models, providerId, dir, this.log),
493
+ modelUnavailableReason: (providerId, modelId) =>
494
+ modelUnavailableReason(this.models, providerId, modelId),
494
495
  log: childLogger(`agent:${inst.id}`),
495
496
  });
496
497
  // When a stale-reload is pending and the last active batch finishes,
@@ -770,10 +771,10 @@ const VERTEX_SA_FILE = ".vertex-sa.json";
770
771
 
771
772
  /**
772
773
  * Validate the agent's chosen provider/model against operator settings and
773
- * return the env vars pi should see. Throws if the provider isn't configured,
774
- * the model isn't enabled, or required credentials are missing — but only
775
- * when a model id is specified; an agent.json with provider but no model
776
- * passes through silently (preserves the pre-merge permissive behavior).
774
+ * return the env vars pi should see. Throws with the shared
775
+ * `modelUnavailableReason()` when the model can't be used — but only when a
776
+ * model id is specified; an agent.json with provider but no model passes
777
+ * through silently (preserves the pre-merge permissive behavior).
777
778
  *
778
779
  * Special case: google-vertex's `serviceAccountKey` field is paste-blob
779
780
  * JSON — we write it to <agentDir>/.vertex-sa.json (0600) and point
@@ -793,47 +794,11 @@ function resolveAndValidateProvider(
793
794
  ): Record<string, string> {
794
795
  const providerId = agentJson.model?.provider;
795
796
  if (!providerId) return {};
796
- const entry = findProviderInCatalog(providerId);
797
- if (!entry) return {};
798
- // Subscription OAuth (tokens in pi's own auth.json) substitutes for
799
- // models.json credentials — pi children resolve auth.json themselves,
800
- // so nothing extra is injected; only the model allowlist still applies.
801
- const oauthConnected =
802
- entry.oauth === true && readStoredCredential(providerId)?.type === "oauth";
803
- const cfg =
804
- models.getProvider(providerId) ??
805
- (oauthConnected ? { credentials: {}, enabledModels: [] } : undefined);
806
797
  const modelId = agentJson.model?.id;
807
-
808
- if (!cfg) {
809
- if (modelId) {
810
- throw new Error(
811
- `agent ${agentId}: provider ${providerId} is not configured in Models settings`,
812
- );
813
- }
814
- return {};
815
- }
816
-
817
798
  if (modelId) {
818
- if (cfg.enabledModels.length === 0 || !cfg.enabledModels.includes(modelId)) {
819
- throw new Error(
820
- `agent ${agentId}: model ${providerId}/${modelId} is not enabled in Models settings`,
821
- );
822
- }
823
- const missing = entry.credentials
824
- .filter((f) => f.required)
825
- .filter((f) => {
826
- const v = cfg.credentials[f.key];
827
- return typeof v !== "string" || v.length === 0;
828
- })
829
- .map((f) => f.label);
830
- if (missing.length > 0 && !oauthConnected) {
831
- throw new Error(
832
- `agent ${agentId}: provider ${providerId} is missing required credentials: ${missing.join(", ")} (set them in Models settings)`,
833
- );
834
- }
799
+ const reason = modelUnavailableReason(models, providerId, modelId);
800
+ if (reason) throw new Error(`agent ${agentId}: ${reason}`);
835
801
  }
836
-
837
802
  return resolveProviderEnv(models, providerId, agentDir, log);
838
803
  }
839
804
 
@@ -67,6 +67,12 @@ export function secretsRoot(cfg: ServerConfig): string {
67
67
  return join(harnessRoot(cfg), ".secrets");
68
68
  }
69
69
 
70
+ /** Pi's config folder for this harness (`PI_CODING_AGENT_DIR`). Under
71
+ * `.secrets/` because its `auth.json` holds sign-in tokens. */
72
+ export function piAgentDir(cfg: ServerConfig): string {
73
+ return join(secretsRoot(cfg), "pi");
74
+ }
75
+
70
76
  /** Path of the harness-wide settings file (currently just `{ timezone }`). */
71
77
  export function harnessJsonFile(cfg: ServerConfig): string {
72
78
  return join(harnessRoot(cfg), "harness.json");
package/src/core/main.ts CHANGED
@@ -2,7 +2,7 @@ import { serve } from "@hono/node-server";
2
2
  import { serveStatic } from "@hono/node-server/serve-static";
3
3
  import { Hono } from "hono";
4
4
  import { randomUUID } from "node:crypto";
5
- import { existsSync, readFileSync, readdirSync, rmSync, statSync } from "node:fs";
5
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync } from "node:fs";
6
6
  import { tmpdir } from "node:os";
7
7
  import { dirname, join, resolve } from "node:path";
8
8
  import { fileURLToPath } from "node:url";
@@ -23,7 +23,7 @@ import { modelsRouter } from "../api/models.js";
23
23
  import { secretsRouter } from "../api/secrets.js";
24
24
  import { maybeHandleWebhook } from "../api/webhook.js";
25
25
  import { AgentManager } from "./agent-manager.js";
26
- import { loadConfig, userPluginsRoot } from "./config.js";
26
+ import { loadConfig, piAgentDir, userPluginsRoot } from "./config.js";
27
27
  import { childLogger, rootLogger } from "./logger.js";
28
28
  import { PluginRegistry } from "./plugin-registry.js";
29
29
 
@@ -50,6 +50,14 @@ async function main(): Promise<void> {
50
50
  const log = rootLogger();
51
51
  log.info({ cfg }, "boot");
52
52
 
53
+ // Pi's config folder (auth.json sign-ins, models.json, settings.json,
54
+ // trust.json) is per harness, not the run user's global ~/.pi/agent, so
55
+ // harnesses and the operator's own `pi` never share or overwrite each
56
+ // other's settings. Pi reads the variable on every lookup; every pi child
57
+ // inherits it via `{...process.env}` (runner.spawnPi).
58
+ process.env.PI_CODING_AGENT_DIR = piAgentDir(cfg);
59
+ mkdirSync(piAgentDir(cfg), { recursive: true, mode: 0o700 });
60
+
53
61
  // Shared secret that authenticates in-harness callers of the agent-messaging
54
62
  // webhook. Generated per boot unless the operator pins one; inherited by
55
63
  // every pi child via `{...process.env}` (runner.spawnPi) so seeded scripts
@@ -0,0 +1,55 @@
1
+ import { readStoredCredential } from "@earendil-works/pi-coding-agent";
2
+ import { findProviderInCatalog } from "./models-catalog.js";
3
+ import type { ModelsStore } from "./models-store.js";
4
+ import type { CredField, ProviderCatalogEntry } from "./types.js";
5
+
6
+ /** Every required credential field has a non-empty stored value. */
7
+ export function requiredCredentialsPresent(
8
+ fields: CredField[],
9
+ stored: Record<string, string>,
10
+ ): boolean {
11
+ return fields
12
+ .filter((f) => f.required)
13
+ .every((f) => {
14
+ const v = stored[f.key];
15
+ return typeof v === "string" && v.length > 0;
16
+ });
17
+ }
18
+
19
+ /**
20
+ * A provider can authenticate when its required credentials are stored or a
21
+ * subscription sign-in is connected (tokens live in pi's own auth.json).
22
+ * OAuth-only providers (no credential fields) need the sign-in.
23
+ */
24
+ export function providerAuthenticated(
25
+ entry: ProviderCatalogEntry,
26
+ stored: Record<string, string>,
27
+ ): boolean {
28
+ const oauthConnected =
29
+ entry.oauth === true && readStoredCredential(entry.id)?.type === "oauth";
30
+ if (entry.credentials.length === 0) return oauthConnected;
31
+ return oauthConnected || requiredCredentialsPresent(entry.credentials, stored);
32
+ }
33
+
34
+ /**
35
+ * Why `providerId/modelId` can't be used right now, or null if it can. The one
36
+ * rule shared by agent startup, the thread-model endpoint and every batch
37
+ * spawn. Providers outside the catalog pass through: pi resolves them from the
38
+ * ambient environment.
39
+ */
40
+ export function modelUnavailableReason(
41
+ models: ModelsStore,
42
+ providerId: string,
43
+ modelId: string,
44
+ ): string | null {
45
+ const entry = findProviderInCatalog(providerId);
46
+ if (!entry) return null;
47
+ const cfg = models.getProvider(providerId);
48
+ if (!providerAuthenticated(entry, cfg?.credentials ?? {})) {
49
+ return `provider ${providerId} has no credentials or sign-in in Models settings`;
50
+ }
51
+ if (!cfg?.enabledModels.includes(modelId)) {
52
+ return `model ${providerId}/${modelId} is not enabled in Models settings`;
53
+ }
54
+ return null;
55
+ }