create-loopengine 0.1.12 → 0.1.14

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/README.md CHANGED
@@ -51,6 +51,7 @@ agents/example-agent.ts Starter agent: persona, one tool, one permission rule
51
51
  adapters/http.ts HTTP API (streaming + non-streaming), routes by agent name
52
52
  adapters/cli.ts Command-line adapter
53
53
  .env.example ANTHROPIC_API_KEY / OPENAI_API_KEY / REDIS_URL
54
+ .create-loopengine.json Which version this project was scaffolded from — see "Upgrading" below
54
55
  ```
55
56
 
56
57
  The registry is multi-agent-capable from the start — seeded with one
@@ -84,6 +85,41 @@ working example. Not wired in here by default: it needs the `composio` CLI
84
85
  installed and an authenticated account (`composio link <toolkit>`) first,
85
86
  so it's a deliberate next step, not part of this starter agent.
86
87
 
88
+ ## Upgrading
89
+
90
+ `npm install loopengine@latest` alone only picks up library-side changes
91
+ — `adapters/http.ts`, `adapters/cli.ts`, and `agent-registry.ts` are
92
+ copied into your project once at scaffold time and never touched by npm
93
+ again, so template improvements (new routes, bug fixes) don't reach an
94
+ existing project on their own. `upgrade` closes that gap with a real
95
+ three-way merge — the same technique `git merge` uses — so your own
96
+ edits to those files survive:
97
+
98
+ ```bash
99
+ npx create-loopengine@latest upgrade
100
+ ```
101
+
102
+ Run from inside your project. It merges the current template against
103
+ whichever `create-loopengine` version your project was originally
104
+ scaffolded from (recorded in `.create-loopengine.json`, written
105
+ automatically at scaffold time — pass `--from <version>` if that file is
106
+ missing, e.g. for a project scaffolded before this command existed).
107
+ Each of the five template-owned files gets one of:
108
+
109
+ - **unchanged** — the template never touched this file between your
110
+ version and the latest; nothing to merge, nothing overwritten.
111
+ - **updated** — merged cleanly; your own edits and the template's own
112
+ changes didn't overlap.
113
+ - **conflict** — you edited the same lines the template changed. The
114
+ file is written with real `<<<<<<<`/`=======`/`>>>>>>>` markers, same
115
+ as any git merge conflict — resolve them by hand, then `npx tsc
116
+ --noEmit` to confirm it builds.
117
+
118
+ `upgrade` never touches `agents/`, `README.md`, `.env.example`, or
119
+ `package.json`'s own dependency versions — run `npm install
120
+ loopengine@latest` (and `actauth`/`skillgarden` if you use them directly)
121
+ separately to pick those up.
122
+
87
123
  ## Status
88
124
 
89
125
  Verified end to end against the real published packages: `npm create
package/dist/cli.d.ts CHANGED
@@ -6,4 +6,31 @@ export interface ScaffoldOptions {
6
6
  templateDir?: string;
7
7
  }
8
8
  export declare function scaffold(options: ScaffoldOptions): string;
9
+ export type UpgradeFileStatus = 'updated' | 'unchanged' | 'conflict' | 'skipped';
10
+ export interface UpgradeFileResult {
11
+ file: string;
12
+ status: UpgradeFileStatus;
13
+ }
14
+ export interface UpgradeResult {
15
+ fromVersion: string;
16
+ toVersion: string;
17
+ files: UpgradeFileResult[];
18
+ }
19
+ export interface UpgradeOptions {
20
+ /** Defaults to process.cwd(). */
21
+ projectDir?: string;
22
+ /** Overrides the version recorded in .create-loopengine.json — required
23
+ * if that file is missing (a project scaffolded before this command
24
+ * existed) or was removed. */
25
+ from?: string;
26
+ /** Test-only override for the "current" template — defaults to this
27
+ * CLI's own bundled template/, same as ScaffoldOptions.templateDir. */
28
+ currentTemplateDir?: string;
29
+ /** Resolves the historical template directory for a given
30
+ * create-loopengine version — defaults to fetchPublishedTemplateDir
31
+ * (a real `npm pack`). Test-only seam to avoid hitting the real
32
+ * registry for a fixture "old" template. */
33
+ resolveBaseTemplateDir?: (version: string) => string;
34
+ }
35
+ export declare function upgradeProject(options?: UpgradeOptions): UpgradeResult;
9
36
  export declare function main(argv?: string[]): void;
package/dist/cli.js CHANGED
@@ -1,11 +1,23 @@
1
1
  #!/usr/bin/env node
2
- import { cpSync, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from 'node:fs';
2
+ import { execFileSync } from 'node:child_process';
3
+ import { cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync, } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
3
5
  import { dirname, join } from 'node:path';
4
6
  import { fileURLToPath } from 'node:url';
5
7
  // Template ships in `template/` next to `dist/`, not inside it — it's a
6
8
  // real, buildable project (its own package.json/tsconfig), not TypeScript
7
9
  // source belonging to this CLI.
8
10
  const DEFAULT_TEMPLATE_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'template');
11
+ // This CLI's own version — embedded in a scaffolded project's own
12
+ // .create-loopengine.json (see scaffold() below) as the future upgrade
13
+ // command's merge base, and used again here as the "to" version once
14
+ // upgradeProject() actually runs. Read at runtime rather than baked in
15
+ // at build time so a locally-linked/packed build always reports its own
16
+ // real package.json, not a stale constant.
17
+ function ownVersion() {
18
+ const pkgPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json');
19
+ return JSON.parse(readFileSync(pkgPath, 'utf8')).version;
20
+ }
9
21
  export function scaffold(options) {
10
22
  const { name } = options;
11
23
  const templateDir = options.templateDir ?? DEFAULT_TEMPLATE_DIR;
@@ -25,13 +37,169 @@ export function scaffold(options) {
25
37
  const readmePath = join(destination, 'README.md');
26
38
  const readme = readFileSync(readmePath, 'utf8').replace('__PROJECT_NAME__', name);
27
39
  writeFileSync(readmePath, readme);
40
+ // Records which create-loopengine version this project's copied,
41
+ // hand-editable files (adapters/http.ts, agent-registry.ts, ...) last
42
+ // matched — the merge base `upgrade` needs to pull in template changes
43
+ // later without clobbering whatever's been hand-edited since. Tracked
44
+ // like package.json/lockfiles (committed, not gitignored): it's project
45
+ // metadata, not a build artifact.
46
+ writeFileSync(join(destination, '.create-loopengine.json'), JSON.stringify({ version: ownVersion() }, null, 2) + '\n');
28
47
  return destination;
29
48
  }
49
+ // ---- upgrade: pulls template improvements into an already-scaffolded
50
+ // project's own copied files (adapters/http.ts, adapters/cli.ts,
51
+ // agent-registry.ts, tsconfig.json, .gitignore) via a real three-way
52
+ // merge — the same technique `git merge`/`rebase` use, applied here
53
+ // because those files are meant to be hand-edited after scaffolding, not
54
+ // re-imported from a package the way core library code is. See
55
+ // scaffold()'s own .create-loopengine.json write above for the "base"
56
+ // version this merge starts from. ----
57
+ // templatePath is where the file lives inside a template/ directory
58
+ // (base or current); projectPath is where the *scaffolded* copy lives —
59
+ // only differs for _gitignore, which scaffold() renames to .gitignore
60
+ // (see its own comment for why the template can't ship it under that
61
+ // name directly). Deliberately narrow: agents/example-agent/** stops
62
+ // being "template" the instant someone builds a real agent on top of it,
63
+ // and package.json's own dependency versions are a separate, simpler
64
+ // `npm install loopengine@latest` left to the operator rather than
65
+ // merged here.
66
+ const UPGRADE_CANDIDATE_FILES = [
67
+ { templatePath: 'adapters/http.ts', projectPath: 'adapters/http.ts' },
68
+ { templatePath: 'adapters/cli.ts', projectPath: 'adapters/cli.ts' },
69
+ { templatePath: 'agent-registry.ts', projectPath: 'agent-registry.ts' },
70
+ { templatePath: 'tsconfig.json', projectPath: 'tsconfig.json' },
71
+ { templatePath: '_gitignore', projectPath: '.gitignore' },
72
+ ];
73
+ // npm keeps every published tarball forever, so the historical template
74
+ // for *any* previously-released version is always fetchable this way —
75
+ // no separate snapshot storage, no git-tag dependency. `npm pack`
76
+ // extracts to a `package/` directory inside whatever `--pack-destination`
77
+ // it's given, same as every other real-package verification already done
78
+ // this way elsewhere in this project's own test suite.
79
+ function fetchPublishedTemplateDir(version) {
80
+ const workDir = mkdtempSync(join(tmpdir(), 'create-loopengine-upgrade-base-'));
81
+ execFileSync('npm', ['pack', `create-loopengine@${version}`, '--pack-destination', workDir], { stdio: 'pipe' });
82
+ const tarball = readdirSync(workDir).find((f) => f.endsWith('.tgz'));
83
+ if (!tarball) {
84
+ throw new Error(`Could not fetch create-loopengine@${version} from npm — check the version exists and you have network access.`);
85
+ }
86
+ // Absolute path, not the bare filename readdirSync returns — tar
87
+ // resolves a relative first argument against the calling process's own
88
+ // cwd (wherever `create-loopengine upgrade` was actually run from), not
89
+ // workDir, even with -C set (-C only affects where extraction lands).
90
+ // Confirmed live: this failed with a wrong-directory ENOENT.
91
+ execFileSync('tar', ['-xzf', join(workDir, tarball), '-C', workDir], { stdio: 'pipe' });
92
+ return join(workDir, 'package', 'template');
93
+ }
94
+ // git merge-file's own exit code *is* its conflict count (0 = clean),
95
+ // not a pass/fail signal — execFileSync throws on any non-zero exit, so
96
+ // a real conflict has to be told apart from git genuinely failing to
97
+ // run (not installed, path error: no `status`, an `ENOENT`-shaped error
98
+ // instead) by checking for a numeric `status` on the caught error.
99
+ // Operates on `minePath` in place (git merge-file's own contract) — always
100
+ // called with a disposable temp copy, never the real project file
101
+ // directly, so the caller decides whether/how to apply the result.
102
+ function threeWayMerge(minePath, basePath, theirsPath) {
103
+ let conflicted = false;
104
+ try {
105
+ execFileSync('git', ['merge-file', '--diff3', '-L', 'mine', '-L', 'base', '-L', 'latest', minePath, basePath, theirsPath], {
106
+ stdio: 'pipe',
107
+ });
108
+ }
109
+ catch (err) {
110
+ if (err && typeof err === 'object' && 'status' in err && typeof err.status === 'number') {
111
+ conflicted = true;
112
+ }
113
+ else {
114
+ throw new Error(`git merge-file failed — is git installed and on PATH? (${err instanceof Error ? err.message : String(err)})`);
115
+ }
116
+ }
117
+ return { merged: readFileSync(minePath, 'utf8'), conflicted };
118
+ }
119
+ function upgradeFile(candidate, baseTemplateDir, currentTemplateDir, projectDir) {
120
+ const projectFilePath = join(projectDir, candidate.projectPath);
121
+ const baseFilePath = join(baseTemplateDir, candidate.templatePath);
122
+ const theirsFilePath = join(currentTemplateDir, candidate.templatePath);
123
+ if (!existsSync(projectFilePath) || !existsSync(baseFilePath) || !existsSync(theirsFilePath)) {
124
+ return { file: candidate.projectPath, status: 'skipped' };
125
+ }
126
+ const base = readFileSync(baseFilePath, 'utf8');
127
+ const theirs = readFileSync(theirsFilePath, 'utf8');
128
+ if (base === theirs) {
129
+ // The template never touched this file between the recorded version
130
+ // and now — nothing to merge, and importantly nothing to overwrite,
131
+ // so a hand-edit here is never even diffed against, let alone at risk.
132
+ return { file: candidate.projectPath, status: 'unchanged' };
133
+ }
134
+ const mine = readFileSync(projectFilePath, 'utf8');
135
+ const scratchDir = mkdtempSync(join(tmpdir(), 'create-loopengine-merge-'));
136
+ const mineTmp = join(scratchDir, 'mine');
137
+ const baseTmp = join(scratchDir, 'base');
138
+ const theirsTmp = join(scratchDir, 'theirs');
139
+ writeFileSync(mineTmp, mine);
140
+ writeFileSync(baseTmp, base);
141
+ writeFileSync(theirsTmp, theirs);
142
+ const { merged, conflicted } = threeWayMerge(mineTmp, baseTmp, theirsTmp);
143
+ rmSync(scratchDir, { recursive: true, force: true });
144
+ writeFileSync(projectFilePath, merged);
145
+ return { file: candidate.projectPath, status: conflicted ? 'conflict' : 'updated' };
146
+ }
147
+ export function upgradeProject(options = {}) {
148
+ const projectDir = options.projectDir ?? process.cwd();
149
+ const currentTemplateDir = options.currentTemplateDir ?? DEFAULT_TEMPLATE_DIR;
150
+ const toVersion = ownVersion();
151
+ const provenancePath = join(projectDir, '.create-loopengine.json');
152
+ let fromVersion = options.from;
153
+ if (!fromVersion) {
154
+ if (!existsSync(provenancePath)) {
155
+ throw new Error(`No .create-loopengine.json in ${projectDir} — this project was scaffolded before \`upgrade\` existed, or the file was removed. Pass --from <version> to specify which create-loopengine version it was originally scaffolded from.`);
156
+ }
157
+ const provenance = JSON.parse(readFileSync(provenancePath, 'utf8'));
158
+ if (!provenance.version) {
159
+ throw new Error(`${provenancePath} is missing its "version" field — pass --from <version> instead.`);
160
+ }
161
+ fromVersion = provenance.version;
162
+ }
163
+ const resolveBase = options.resolveBaseTemplateDir ?? fetchPublishedTemplateDir;
164
+ const baseTemplateDir = resolveBase(fromVersion);
165
+ const files = UPGRADE_CANDIDATE_FILES.map((candidate) => upgradeFile(candidate, baseTemplateDir, currentTemplateDir, projectDir));
166
+ writeFileSync(provenancePath, JSON.stringify({ version: toVersion }, null, 2) + '\n');
167
+ return { fromVersion, toVersion, files };
168
+ }
169
+ function parseUpgradeArgs(argv) {
170
+ let from;
171
+ for (let i = 0; i < argv.length; i++) {
172
+ if (argv[i] === '--from')
173
+ from = argv[++i];
174
+ }
175
+ return { from };
176
+ }
177
+ function printUpgradeSummary(result) {
178
+ console.log(`Upgrading create-loopengine@${result.fromVersion} -> @${result.toVersion}`);
179
+ console.log();
180
+ for (const f of result.files) {
181
+ const label = f.status === 'updated' ? 'updated ' : f.status === 'conflict' ? 'conflict ' : f.status === 'unchanged' ? 'unchanged' : 'skipped ';
182
+ console.log(` ${label} ${f.file}`);
183
+ }
184
+ console.log();
185
+ const conflicted = result.files.filter((f) => f.status === 'conflict');
186
+ if (conflicted.length > 0) {
187
+ console.log(`${conflicted.length} file(s) have <<<<<<< conflict markers to resolve by hand before this will build.`);
188
+ }
189
+ else {
190
+ console.log('No conflicts. Run `npm install loopengine@latest` to pick up any library-side changes too.');
191
+ }
192
+ }
30
193
  function parseArgs(argv) {
31
194
  return { name: argv[0] ?? 'my-agents' };
32
195
  }
33
196
  export function main(argv = process.argv.slice(2)) {
34
197
  try {
198
+ if (argv[0] === 'upgrade') {
199
+ const { from } = parseUpgradeArgs(argv.slice(1));
200
+ printUpgradeSummary(upgradeProject({ from }));
201
+ return;
202
+ }
35
203
  const { name } = parseArgs(argv);
36
204
  const destination = scaffold({ name });
37
205
  console.log(`Created ${destination}/`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-loopengine",
3
- "version": "0.1.12",
3
+ "version": "0.1.14",
4
4
  "description": "Scaffold a new loopengine agent project — a runnable HTTP server with one example agent, in your own repo.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -43,6 +43,7 @@ import {
43
43
  AgentEditNotSupportedError,
44
44
  AgentFileNotFoundError,
45
45
  type AgentEditResult,
46
+ type AgentEditableFields,
46
47
  createSessionStore,
47
48
  createCheckpointStore,
48
49
  type TurnCheckpoint,
@@ -79,6 +80,10 @@ import {
79
80
  deleteSkill,
80
81
  SkillInvalidIdError,
81
82
  SkillNotFoundError,
83
+ listSkillgardenCatalog,
84
+ readSkillgardenCatalogEntry,
85
+ addSkillgardenSkillToAgent,
86
+ SkillgardenUnavailableError,
82
87
  readActauthConfig,
83
88
  addActauthRule,
84
89
  updateActauthRule,
@@ -314,9 +319,14 @@ async function describeAgent(entry: RegistryEntry): Promise<Record<string, unkno
314
319
  // configured for *this* agent, so the config page's own Skills tab
315
320
  // deliberately doesn't mix it into skills/skillsDirs below.
316
321
  const skillsDirs = config.skillsDirs ?? [`agents/${config.name}/skills`]
317
- const skillGarden = new SkillGarden({ dirs: skillsDirs, indexBudgetTokens: config.skillIndexBudgetTokens ?? 200 })
322
+ // Same defaults, same reasoning, as loopengine's own run-agent.ts
323
+ // SkillGarden/BudgetTracker construction — kept in sync there and here
324
+ // since this is what a "Skills"/"Limits & budgets" tab shows an
325
+ // operator, and it has to describe what runAgent() would actually
326
+ // resolve, not a second guess (see this function's own header comment).
327
+ const skillGarden = new SkillGarden({ dirs: skillsDirs, indexBudgetTokens: config.skillIndexBudgetTokens ?? 2000 })
318
328
  const skillIndex = skillGarden.buildIndex().included
319
- const systemSkillGarden = new SkillGarden({ dirs: [systemSkillsDir], indexBudgetTokens: config.skillIndexBudgetTokens ?? 200 })
329
+ const systemSkillGarden = new SkillGarden({ dirs: [systemSkillsDir], indexBudgetTokens: config.skillIndexBudgetTokens ?? 2000 })
320
330
  const systemSkillIndex = systemSkillGarden.buildIndex().included
321
331
 
322
332
  return {
@@ -326,8 +336,8 @@ async function describeAgent(entry: RegistryEntry): Promise<Record<string, unkno
326
336
  ? { provider: config.model.provider, model: config.model.model, maxTokens: config.model.maxTokens }
327
337
  : 'custom (module exports its own createModelCall)',
328
338
  maxTurns: config.maxTurns ?? 25,
329
- contextBudgetTokens: config.contextBudgetTokens ?? 8000,
330
- skillIndexBudgetTokens: config.skillIndexBudgetTokens ?? 200,
339
+ contextBudgetTokens: config.contextBudgetTokens ?? 100000,
340
+ skillIndexBudgetTokens: config.skillIndexBudgetTokens ?? 2000,
331
341
  skillsDirs: skillsDirs,
332
342
  skills: skillIndex.map((s) => ({ name: s.name, description: s.description })),
333
343
  systemSkills: systemSkillIndex.map((s) => ({ name: s.name, description: s.description })),
@@ -644,6 +654,33 @@ function parseAgentTemplateOptions(body: Record<string, unknown>): { ok: true; v
644
654
  return { ok: true, value: options }
645
655
  }
646
656
 
657
+ // The Overview tab's "Limits & budgets" edit form — kept separate from
658
+ // parseAgentTemplateOptions since that one's shape (AgentTemplateOptions)
659
+ // is shared with handleCreateAgent/scaffoldAgent, which has no notion of
660
+ // these three at all (a freshly scaffolded agent always starts on
661
+ // loopengine's own defaults — see AgentConfig's own doc comments). Each
662
+ // of the three is optional and independent, same as systemPrompt/model
663
+ // above.
664
+ function parsePositiveInt(value: unknown, field: string): { ok: true; value: number | undefined } | { ok: false; error: string } {
665
+ if (value === undefined || value === null) return { ok: true, value: undefined }
666
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < 1) {
667
+ return { ok: false, error: `${field} must be a positive integer` }
668
+ }
669
+ return { ok: true, value }
670
+ }
671
+
672
+ function parseAgentLimitsFields(
673
+ body: Record<string, unknown>,
674
+ ): { ok: true; value: Pick<AgentEditableFields, 'maxTurns' | 'contextBudgetTokens' | 'skillIndexBudgetTokens'> } | { ok: false; error: string } {
675
+ const value: Pick<AgentEditableFields, 'maxTurns' | 'contextBudgetTokens' | 'skillIndexBudgetTokens'> = {}
676
+ for (const field of ['maxTurns', 'contextBudgetTokens', 'skillIndexBudgetTokens'] as const) {
677
+ const parsed = parsePositiveInt(body[field], field)
678
+ if (!parsed.ok) return parsed
679
+ if (parsed.value !== undefined) value[field] = parsed.value
680
+ }
681
+ return { ok: true, value }
682
+ }
683
+
647
684
  async function handleCreateAgent(req: IncomingMessage, res: ServerResponse): Promise<void> {
648
685
  const body = await readJsonBody(req)
649
686
  if (typeof body.name !== 'string' || !body.name) {
@@ -677,9 +714,11 @@ async function handleCreateAgent(req: IncomingMessage, res: ServerResponse): Pro
677
714
  res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ path: indexPath, registered: true }))
678
715
  }
679
716
 
680
- // Backs the Overview tab's System prompt / Model edit forms — either
681
- // field can be sent independently (see parseAgentTemplateOptions), the
682
- // other stays untouched both on disk and live. Persists via
717
+ // Backs the Overview tab's System prompt / Model / Limits & budgets edit
718
+ // forms — any subset of systemPrompt, model, maxTurns, contextBudgetTokens,
719
+ // skillIndexBudgetTokens can be sent independently (see
720
+ // parseAgentTemplateOptions/parseAgentLimitsFields), every other field
721
+ // stays untouched both on disk and live. Persists via
683
722
  // agent-file-admin.ts's editAgentFile (see its own doc comment for why
684
723
  // this is a surgical AST edit, not a full file regeneration), then
685
724
  // applies the exact same resolved values to *this* running process via
@@ -699,14 +738,22 @@ async function handleEditAgent(req: IncomingMessage, res: ServerResponse, agentN
699
738
  res.writeHead(400, { 'content-type': 'application/json' }).end(JSON.stringify({ error: parsedOptions.error }))
700
739
  return
701
740
  }
702
- if (parsedOptions.value.systemPrompt === undefined && parsedOptions.value.model === undefined) {
703
- res.writeHead(400, { 'content-type': 'application/json' }).end(JSON.stringify({ error: 'systemPrompt or model is required' }))
741
+ const parsedLimits = parseAgentLimitsFields(body)
742
+ if (!parsedLimits.ok) {
743
+ res.writeHead(400, { 'content-type': 'application/json' }).end(JSON.stringify({ error: parsedLimits.error }))
744
+ return
745
+ }
746
+ const editFields: AgentEditableFields = { ...parsedOptions.value, ...parsedLimits.value }
747
+ if (Object.keys(editFields).length === 0) {
748
+ res
749
+ .writeHead(400, { 'content-type': 'application/json' })
750
+ .end(JSON.stringify({ error: 'systemPrompt, model, maxTurns, contextBudgetTokens, or skillIndexBudgetTokens is required' }))
704
751
  return
705
752
  }
706
753
 
707
754
  let result: AgentEditResult
708
755
  try {
709
- result = editAgentFile(agentName, parsedOptions.value)
756
+ result = editAgentFile(agentName, editFields)
710
757
  } catch (err) {
711
758
  const status =
712
759
  err instanceof AgentEditNotSupportedError || err instanceof AgentModelError ? 400 : err instanceof AgentFileNotFoundError ? 404 : 500
@@ -716,6 +763,9 @@ async function handleEditAgent(req: IncomingMessage, res: ServerResponse, agentN
716
763
 
717
764
  const configPatch: Partial<AgentConfig> = {}
718
765
  if (result.systemPrompt !== undefined) configPatch.systemPrompt = result.systemPrompt
766
+ if (result.maxTurns !== undefined) configPatch.maxTurns = result.maxTurns
767
+ if (result.contextBudgetTokens !== undefined) configPatch.contextBudgetTokens = result.contextBudgetTokens
768
+ if (result.skillIndexBudgetTokens !== undefined) configPatch.skillIndexBudgetTokens = result.skillIndexBudgetTokens
719
769
  let createModelCall: RegistryEntry['createModelCall'] | undefined
720
770
  if (result.model) {
721
771
  configPatch.model = result.model
@@ -782,6 +832,69 @@ function handleSkillDelete(res: ServerResponse, agentName: string, skillId: stri
782
832
  res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ ok: true }))
783
833
  }
784
834
 
835
+ // Backs the Skills tab's "Browse skillgarden catalog" section — a
836
+ // read-only, agent-independent list of everything skillgarden ships in
837
+ // its own bundled registry (see loopengine's own web/skillgarden-admin.ts).
838
+ // Not wrapped in a per-agent :name segment since the catalog itself
839
+ // isn't scoped to any one agent; only the POST below (actually
840
+ // installing an entry) is.
841
+ function handleSkillgardenCatalogGet(res: ServerResponse): void {
842
+ try {
843
+ // Computed before writeHead, not inlined into .end()'s own argument —
844
+ // res.writeHead(200, ...) commits the status line the instant it's
845
+ // called, so a throw evaluated *after* it (as it would be if it were
846
+ // inlined as .end()'s argument) leaves headers already sent as 200
847
+ // with no body, and the catch block's own writeHead(status, ...)
848
+ // then fails outright (ERR_HTTP_HEADERS_SENT) instead of ever
849
+ // reaching the client.
850
+ const catalog = listSkillgardenCatalog()
851
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(catalog))
852
+ } catch (err) {
853
+ const status = err instanceof SkillgardenUnavailableError ? 503 : 500
854
+ res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
855
+ }
856
+ }
857
+
858
+ // Full SKILL.md body for the catalog's own "View" toggle — kept as a
859
+ // separate route from the list above (rather than inlining every body
860
+ // into it) for the same reason the Skills tab's own edit form fetches a
861
+ // skill's body lazily instead of shipping it in the initial list.
862
+ function handleSkillgardenCatalogEntryGet(res: ServerResponse, category: string, id: string): void {
863
+ try {
864
+ // See handleSkillgardenCatalogGet's own comment for why this is
865
+ // computed before writeHead rather than inlined into .end()'s
866
+ // argument.
867
+ const entry = readSkillgardenCatalogEntry(category, id)
868
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(entry))
869
+ } catch (err) {
870
+ const status = err instanceof SkillgardenUnavailableError ? 503 : 404
871
+ res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
872
+ }
873
+ }
874
+
875
+ // Installs a catalog entry into agents/:name/skills/ — delegates to
876
+ // skillgarden's own addSkill (see skillgarden-admin.ts), which throws
877
+ // plain Errors (no typed classes) for "unknown skill"/"already exists",
878
+ // so those two are told apart by message shape rather than instanceof,
879
+ // same as any other dependency that doesn't export typed errors for its
880
+ // own failure modes.
881
+ async function handleSkillgardenCatalogAdd(req: IncomingMessage, res: ServerResponse, agentName: string, category: string, id: string): Promise<void> {
882
+ if (!getEntry(agentName)) {
883
+ res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
884
+ return
885
+ }
886
+ const body = await readJsonBody(req)
887
+ const force = body.force === true
888
+ try {
889
+ const result = addSkillgardenSkillToAgent(agentName, category, id, force)
890
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ ok: true, id: result.id }))
891
+ } catch (err) {
892
+ const message = err instanceof Error ? err.message : String(err)
893
+ const status = err instanceof SkillgardenUnavailableError ? 503 : /already exists/.test(message) ? 409 : /^Unknown skill/.test(message) ? 404 : 500
894
+ res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: message }))
895
+ }
896
+ }
897
+
785
898
  // Backs the Actauth tab's rule editor — parses a rule body shared by both
786
899
  // the add (POST) and update (PUT, minus `name`) routes below.
787
900
  function parseActauthRuleBody(body: Record<string, unknown>, requireName: boolean): { ok: true; value: { name: string; scope: string; tool: string; decision: Decision } } | { ok: false; error: string } {
@@ -1906,6 +2019,23 @@ const server = createServer(async (req, res) => {
1906
2019
  return
1907
2020
  }
1908
2021
 
2022
+ // Backs the Skills tab's "Browse skillgarden catalog" section — see
2023
+ // the three handlers above it.
2024
+ if (req.method === 'GET' && pathname === '/skillgarden-catalog') {
2025
+ handleSkillgardenCatalogGet(res)
2026
+ return
2027
+ }
2028
+ const skillgardenEntryMatch = req.method === 'GET' && pathname.match(/^\/skillgarden-catalog\/([^/]+)\/([^/]+)$/)
2029
+ if (skillgardenEntryMatch) {
2030
+ handleSkillgardenCatalogEntryGet(res, decodeURIComponent(skillgardenEntryMatch[1]), decodeURIComponent(skillgardenEntryMatch[2]))
2031
+ return
2032
+ }
2033
+ const skillgardenAddMatch = req.method === 'POST' && pathname.match(/^\/agents\/([^/]+)\/skillgarden-catalog\/([^/]+)\/([^/]+)$/)
2034
+ if (skillgardenAddMatch) {
2035
+ await handleSkillgardenCatalogAdd(req, res, decodeURIComponent(skillgardenAddMatch[1]), decodeURIComponent(skillgardenAddMatch[2]), decodeURIComponent(skillgardenAddMatch[3]))
2036
+ return
2037
+ }
2038
+
1909
2039
  // Backs the Actauth tab's rule editor and default_decision control.
1910
2040
  // The default-decision route is checked before the :ruleName routes
1911
2041
  // right below since 'default-decision' would otherwise itself match
@@ -9,9 +9,9 @@
9
9
  "cli": "tsx adapters/cli.ts"
10
10
  },
11
11
  "dependencies": {
12
- "loopengine": "^0.1.4",
12
+ "loopengine": "^0.1.6",
13
13
  "actauth": "^0.0.12",
14
- "skillgarden": "^0.0.5"
14
+ "skillgarden": "^0.0.9"
15
15
  },
16
16
  "devDependencies": {
17
17
  "@types/node": "^22.10.0",