agentcraft 0.0.1 → 0.0.3

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.
@@ -0,0 +1,121 @@
1
+ # Sound Pack System Design
2
+
3
+ **Date:** 2026-02-22
4
+ **Status:** Approved
5
+
6
+ ## Problem
7
+
8
+ The current `~/.agentcraft/sounds/` directory is a flat, monolithic library cloned from one repo. There is no way for users to add additional sound packs, no way to ship UI themes as separate installable units, and no concept of publisher or pack identity in assignment paths. The system assumes exactly one sound library exists.
9
+
10
+ ## Goal
11
+
12
+ A pack system that:
13
+ - Lets users install sound packs from any GitHub repo via `agentcraft pack install publisher/name`
14
+ - Works drop-in: manually cloning a repo into the right directory is equivalent to installing
15
+ - Keeps packs isolated — no file conflicts between publishers
16
+ - Migrates the existing `agentcraft-sounds` library cleanly
17
+ - Powers the `agentcraft` npm CLI (already registered at 0.0.1)
18
+
19
+ ## Directory Layout
20
+
21
+ ```
22
+ ~/.agentcraft/
23
+ assignments.json — sound assignments (paths include pack prefix)
24
+ waveforms.json — waveform cache
25
+ packs/
26
+ rohenaz/
27
+ agentcraft-sounds/ — official pack (migrated from ~/.agentcraft/sounds/)
28
+ sc2/
29
+ wc3/
30
+ ff7/
31
+ ff9/
32
+ apps/
33
+ classic-os/
34
+ phones/
35
+ ui/ — UI theme sounds
36
+ sc2/
37
+ wc3/
38
+ ff7/
39
+ ff9/
40
+ sc-bigbox/
41
+ community-publisher/
42
+ halo-pack/ — example third-party pack
43
+ sounds/
44
+ ui/
45
+ ```
46
+
47
+ No manifest file required. Any directory at depth `packs/<publisher>/<name>/` is a valid pack. A `pack.json` is optional metadata (name, description, version) for display purposes only.
48
+
49
+ ## Assignment Path Format
50
+
51
+ Paths in `assignments.json` gain a pack prefix separated by `:`:
52
+
53
+ ```
54
+ rohenaz/agentcraft-sounds:sc2/terran/session-start/scv-ready.mp3
55
+ ```
56
+
57
+ The hook script resolves this to:
58
+ ```
59
+ ~/.agentcraft/packs/rohenaz/agentcraft-sounds/sc2/terran/session-start/scv-ready.mp3
60
+ ```
61
+
62
+ Paths without a `:` prefix are legacy paths (from `~/.agentcraft/sounds/`) and fall back to that directory during a migration grace period.
63
+
64
+ ## Default Configuration
65
+
66
+ The user's current configuration is saved as `defaults/assignments.json` in the `agentcraft-sounds` repo. On first install, if no `~/.agentcraft/assignments.json` exists, the CLI copies this file as the starting configuration.
67
+
68
+ ## CLI (`agentcraft` npm package)
69
+
70
+ ```bash
71
+ agentcraft pack install rohenaz/agentcraft-sounds # git clone into packs/
72
+ agentcraft pack install rohenaz/agentcraft-sounds --branch dev # specific branch
73
+ agentcraft pack list # show all installed packs
74
+ agentcraft pack remove rohenaz/agentcraft-sounds # rm -rf
75
+ agentcraft pack update rohenaz/agentcraft-sounds # git pull
76
+ agentcraft pack update --all # pull all packs
77
+ agentcraft start # launch dashboard on port 4040
78
+ ```
79
+
80
+ Install resolves `publisher/name` to `https://github.com/publisher/name` and clones into `~/.agentcraft/packs/publisher/name/`.
81
+
82
+ ## Web UI Changes
83
+
84
+ ### Sound Browser
85
+ - Groups sounds by pack in the category filter
86
+ - Pack shown as top-level grouping: `rohenaz/agentcraft-sounds > sc2 > terran`
87
+ - Search spans all packs
88
+
89
+ ### API Routes
90
+ - `/api/sounds` — scans `~/.agentcraft/packs/` recursively, prefixes all paths with `publisher/name:`
91
+ - `/api/audio/[...path]` — resolves `publisher/name:internal/path` to filesystem path
92
+ - `/api/ui-sounds` — scans `ui/` directory across all installed packs
93
+
94
+ ### Migration
95
+ - On startup, if `~/.agentcraft/sounds/` exists and `~/.agentcraft/packs/` does not, the server symlinks `~/.agentcraft/packs/rohenaz/agentcraft-sounds` → `~/.agentcraft/sounds/` automatically
96
+ - Assignment paths without `:` prefix resolve against `rohenaz/agentcraft-sounds` as the default pack
97
+
98
+ ## `/agentcraft` Slash Command Changes
99
+
100
+ The auto-clone on first run targets `~/.agentcraft/packs/rohenaz/agentcraft-sounds` instead of `~/.agentcraft/sounds/`. Uses `agentcraft pack install` if CLI is available, falls back to raw `git clone`.
101
+
102
+ ## `pack.json` (Optional)
103
+
104
+ ```json
105
+ {
106
+ "name": "agentcraft-sounds",
107
+ "publisher": "rohenaz",
108
+ "version": "2.0.0",
109
+ "description": "Official AgentCraft sound library — SC2, WC3, FF7, FF9, phones, classic OS",
110
+ "types": ["sounds", "ui"]
111
+ }
112
+ ```
113
+
114
+ `types` is informational — the browser detects what's present by scanning the directory.
115
+
116
+ ## Out of Scope (Future)
117
+
118
+ - Central pack registry / discovery
119
+ - Pack ratings or featured packs
120
+ - Signed packs / integrity verification
121
+ - Pack versioning / lockfile
@@ -1,7 +1,7 @@
1
1
  #!/bin/bash
2
2
  # AgentCraft hook - plays assigned sound for this event/agent/skill
3
3
  CONFIG="$HOME/.agentcraft/assignments.json"
4
- LIBRARY="$HOME/.agentcraft/sounds"
4
+ PACKS="$HOME/.agentcraft/packs"
5
5
 
6
6
  # Read stdin with 2s timeout. 'timeout' isn't available by default on macOS,
7
7
  # so use perl's alarm() which is always present.
@@ -55,7 +55,18 @@ fi
55
55
  [ -z "$SOUND" ] && SOUND=$(jq -r --arg e "$EVENT" '.global[$e] // empty' "$CONFIG")
56
56
  [ -z "$SOUND" ] && exit 0
57
57
 
58
- FULL="$LIBRARY/$SOUND"
58
+ # Resolve pack-prefixed path: "publisher/name:internal/path"
59
+ if echo "$SOUND" | grep -q ':'; then
60
+ PACK_ID="${SOUND%%:*}"
61
+ INTERNAL="${SOUND#*:}"
62
+ PUBLISHER="${PACK_ID%%/*}"
63
+ PACKNAME="${PACK_ID##*/}"
64
+ FULL="$PACKS/$PUBLISHER/$PACKNAME/$INTERNAL"
65
+ else
66
+ # Legacy path — fall back to rohenaz/agentcraft-sounds
67
+ FULL="$PACKS/rohenaz/agentcraft-sounds/$SOUND"
68
+ fi
69
+
59
70
  [ ! -f "$FULL" ] && exit 0
60
71
 
61
72
  if [[ "$OSTYPE" == "darwin"* ]]; then
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentcraft",
3
- "version": "0.0.1",
3
+ "version": "0.0.3",
4
4
  "description": "Assign sounds to AI coding agent lifecycle events. CLI for managing sound packs.",
5
5
  "license": "MIT",
6
6
  "author": "rohenaz",
@@ -1,9 +1,7 @@
1
1
  import { NextRequest, NextResponse } from 'next/server';
2
2
  import { readFile, writeFile, mkdir } from 'fs/promises';
3
- import { join, dirname } from 'path';
4
- import { homedir } from 'os';
5
-
6
- const ASSIGNMENTS_PATH = join(homedir(), '.agentcraft', 'assignments.json');
3
+ import { dirname } from 'path';
4
+ import { ASSIGNMENTS_PATH } from '@/lib/packs';
7
5
 
8
6
  export async function GET() {
9
7
  try {
@@ -1,9 +1,6 @@
1
1
  import { NextRequest, NextResponse } from 'next/server';
2
2
  import { readFile } from 'fs/promises';
3
- import { join } from 'path';
4
- import { homedir } from 'os';
5
-
6
- const SOUND_LIBRARY = join(homedir(), '.agentcraft', 'sounds');
3
+ import { resolvePackPath } from '@/lib/packs';
7
4
 
8
5
  export async function GET(
9
6
  req: NextRequest,
@@ -11,23 +8,17 @@ export async function GET(
11
8
  ) {
12
9
  try {
13
10
  const { path: pathParts } = await params;
14
- const relativePath = pathParts.join('/');
15
-
16
- if (relativePath.includes('..')) {
11
+ const soundPath = decodeURIComponent(pathParts.join('/'));
12
+ if (soundPath.includes('..')) {
17
13
  return NextResponse.json({ error: 'Invalid path' }, { status: 400 });
18
14
  }
19
-
20
- const fullPath = join(SOUND_LIBRARY, relativePath);
15
+ const fullPath = resolvePackPath(soundPath);
16
+ if (!fullPath) return NextResponse.json({ error: 'Invalid path' }, { status: 400 });
21
17
  const data = await readFile(fullPath);
22
-
23
- const ext = relativePath.split('.').pop()?.toLowerCase();
18
+ const ext = soundPath.split('.').pop()?.toLowerCase();
24
19
  const contentType = ext === 'mp3' ? 'audio/mpeg' : ext === 'wav' ? 'audio/wav' : 'audio/mpeg';
25
-
26
20
  return new NextResponse(data, {
27
- headers: {
28
- 'Content-Type': contentType,
29
- 'Cache-Control': 'public, max-age=86400',
30
- },
21
+ headers: { 'Content-Type': contentType, 'Cache-Control': 'public, max-age=86400' },
31
22
  });
32
23
  } catch {
33
24
  return NextResponse.json({ error: 'Audio file not found' }, { status: 404 });
@@ -0,0 +1,109 @@
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+ import { readdir, stat, readFile } from 'fs/promises';
3
+ import { join } from 'path';
4
+ import { homedir } from 'os';
5
+ import { spawnSync } from 'child_process';
6
+
7
+ const PACKS_DIR = join(homedir(), '.agentcraft', 'packs');
8
+
9
+ interface PackInfo {
10
+ id: string; // "publisher/name"
11
+ publisher: string;
12
+ name: string;
13
+ description?: string;
14
+ version?: string;
15
+ }
16
+
17
+ async function getInstalledPacks(): Promise<PackInfo[]> {
18
+ const packs: PackInfo[] = [];
19
+ let publishers: string[];
20
+ try {
21
+ publishers = await readdir(PACKS_DIR);
22
+ } catch {
23
+ return packs;
24
+ }
25
+ for (const publisher of publishers) {
26
+ const pubPath = join(PACKS_DIR, publisher);
27
+ const ps = await stat(pubPath).catch(() => null);
28
+ if (!ps?.isDirectory()) continue;
29
+ const names = await readdir(pubPath).catch(() => [] as string[]);
30
+ for (const name of names) {
31
+ const packPath = join(pubPath, name);
32
+ const ns = await stat(packPath).catch(() => null);
33
+ if (!ns?.isDirectory()) continue;
34
+ // Try to read pack.json for metadata
35
+ let description: string | undefined;
36
+ let version: string | undefined;
37
+ try {
38
+ const manifest = JSON.parse(await readFile(join(packPath, 'pack.json'), 'utf-8'));
39
+ description = manifest.description;
40
+ version = manifest.version;
41
+ } catch { /* no manifest, that's fine */ }
42
+ packs.push({ id: `${publisher}/${name}`, publisher, name, description, version });
43
+ }
44
+ }
45
+ return packs;
46
+ }
47
+
48
+ // GET — list installed packs
49
+ export async function GET() {
50
+ const packs = await getInstalledPacks();
51
+ return NextResponse.json(packs);
52
+ }
53
+
54
+ // POST { repo: "publisher/name" } — install a pack
55
+ export async function POST(req: NextRequest) {
56
+ try {
57
+ const { repo } = await req.json();
58
+ if (!repo || !/^[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+$/.test(repo)) {
59
+ return NextResponse.json({ error: 'Invalid repo format' }, { status: 400 });
60
+ }
61
+ const [publisher, name] = repo.split('/');
62
+ const dest = join(PACKS_DIR, publisher, name);
63
+ const url = `https://github.com/${repo}`;
64
+ // mkdir publisher dir
65
+ spawnSync('mkdir', ['-p', join(PACKS_DIR, publisher)]);
66
+ const result = spawnSync('git', ['clone', url, dest], { timeout: 60000 });
67
+ if (result.status !== 0) {
68
+ return NextResponse.json({ error: 'Clone failed' }, { status: 500 });
69
+ }
70
+ return NextResponse.json({ ok: true });
71
+ } catch {
72
+ return NextResponse.json({ error: 'Install failed' }, { status: 500 });
73
+ }
74
+ }
75
+
76
+ // DELETE { repo: "publisher/name" } — remove a pack
77
+ export async function DELETE(req: NextRequest) {
78
+ try {
79
+ const { repo } = await req.json();
80
+ if (!repo || !/^[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+$/.test(repo)) {
81
+ return NextResponse.json({ error: 'Invalid repo format' }, { status: 400 });
82
+ }
83
+ const [publisher, name] = repo.split('/');
84
+ const dest = join(PACKS_DIR, publisher, name);
85
+ spawnSync('rm', ['-rf', dest]);
86
+ return NextResponse.json({ ok: true });
87
+ } catch {
88
+ return NextResponse.json({ error: 'Remove failed' }, { status: 500 });
89
+ }
90
+ }
91
+
92
+ // PATCH { repo: "publisher/name" } — update a pack (git pull)
93
+ export async function PATCH(req: NextRequest) {
94
+ try {
95
+ const { repo } = await req.json();
96
+ if (!repo || !/^[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+$/.test(repo)) {
97
+ return NextResponse.json({ error: 'Invalid repo format' }, { status: 400 });
98
+ }
99
+ const [publisher, name] = repo.split('/');
100
+ const dest = join(PACKS_DIR, publisher, name);
101
+ const result = spawnSync('git', ['-C', dest, 'pull'], { timeout: 30000 });
102
+ if (result.status !== 0) {
103
+ return NextResponse.json({ error: 'Update failed' }, { status: 500 });
104
+ }
105
+ return NextResponse.json({ ok: true });
106
+ } catch {
107
+ return NextResponse.json({ error: 'Update failed' }, { status: 500 });
108
+ }
109
+ }
@@ -1,20 +1,16 @@
1
1
  import { NextRequest, NextResponse } from 'next/server';
2
2
  import { spawn } from 'child_process';
3
- import { join } from 'path';
4
- import { homedir } from 'os';
5
-
6
- const SOUND_LIBRARY = join(homedir(), '.agentcraft', 'sounds');
3
+ import { resolvePackPath } from '@/lib/packs';
7
4
 
8
5
  export async function POST(req: NextRequest) {
9
6
  try {
10
7
  const { path } = await req.json();
11
- if (!path || typeof path !== 'string' || path.includes('..')) {
8
+ if (!path || typeof path !== 'string') {
12
9
  return NextResponse.json({ error: 'Invalid path' }, { status: 400 });
13
10
  }
14
-
15
- const fullPath = join(SOUND_LIBRARY, path);
11
+ const fullPath = resolvePackPath(path);
12
+ if (!fullPath) return NextResponse.json({ error: 'Invalid path' }, { status: 400 });
16
13
  spawn('afplay', [fullPath], { detached: true, stdio: 'ignore' }).unref();
17
-
18
14
  return NextResponse.json({ ok: true });
19
15
  } catch {
20
16
  return NextResponse.json({ error: 'Playback failed' }, { status: 500 });
@@ -1,32 +1,22 @@
1
1
  import { NextResponse } from 'next/server';
2
- import { readdir, readFile, writeFile, mkdir } from 'fs/promises';
2
+ import { readFile, writeFile, mkdir } from 'fs/promises';
3
3
  import { join, dirname } from 'path';
4
- import { homedir } from 'os';
5
4
  import { spawnSync } from 'child_process';
5
+ import { listPacks, walkPackDir, WAVEFORM_CACHE } from '@/lib/packs';
6
6
  import type { SoundAsset } from '@/lib/types';
7
7
 
8
- const SOUND_LIBRARY = join(homedir(), '.agentcraft', 'sounds');
9
- const WAVEFORM_CACHE = join(homedir(), '.agentcraft', 'waveforms.json');
10
8
  const BARS = 16;
11
9
  const FALLBACK = [5, 7, 4, 9, 6, 8, 3, 7, 5, 8, 4, 6, 7, 5, 8, 4];
12
10
 
13
11
  function computeWaveform(filePath: string): number[] {
14
- // Decode to mono f32le PCM at 1000 Hz (massively downsampled — fast, still accurate)
15
12
  const result = spawnSync('ffmpeg', [
16
- '-i', filePath,
17
- '-ac', '1', // mono
18
- '-ar', '1000', // 1000 Hz sample rate
19
- '-f', 'f32le', // raw 32-bit float output
20
- '-', // pipe to stdout
13
+ '-i', filePath, '-ac', '1', '-ar', '1000', '-f', 'f32le', '-',
21
14
  ], { maxBuffer: 1024 * 1024 * 4 });
22
-
23
15
  if (result.status !== 0 || !result.stdout?.length) return FALLBACK;
24
-
25
16
  const buf = result.stdout as Buffer;
26
- const samples = buf.length / 4; // 4 bytes per f32
17
+ const samples = buf.length / 4;
27
18
  const blockSize = Math.floor(samples / BARS);
28
19
  const values: number[] = [];
29
-
30
20
  for (let i = 0; i < BARS; i++) {
31
21
  let sum = 0;
32
22
  for (let j = 0; j < blockSize; j++) {
@@ -34,17 +24,13 @@ function computeWaveform(filePath: string): number[] {
34
24
  }
35
25
  values.push(sum / blockSize);
36
26
  }
37
-
38
27
  const max = Math.max(...values, 0.0001);
39
28
  return values.map((v) => Math.max(1, Math.round((v / max) * 10)));
40
29
  }
41
30
 
42
31
  async function loadCache(): Promise<Record<string, number[]>> {
43
- try {
44
- return JSON.parse(await readFile(WAVEFORM_CACHE, 'utf-8'));
45
- } catch {
46
- return {};
47
- }
32
+ try { return JSON.parse(await readFile(WAVEFORM_CACHE, 'utf-8')); }
33
+ catch { return {}; }
48
34
  }
49
35
 
50
36
  async function saveCache(cache: Record<string, number[]>) {
@@ -52,53 +38,45 @@ async function saveCache(cache: Record<string, number[]>) {
52
38
  await writeFile(WAVEFORM_CACHE, JSON.stringify(cache), 'utf-8');
53
39
  }
54
40
 
55
- async function walkDir(dir: string, base: string): Promise<SoundAsset[]> {
56
- const assets: SoundAsset[] = [];
57
- const entries = await readdir(dir, { withFileTypes: true });
41
+ export async function GET() {
42
+ try {
43
+ const packs = await listPacks();
44
+ const allFiles: Array<{ relPath: string; absPath: string }> = [];
45
+ for (const pack of packs) {
46
+ const files = await walkPackDir(pack.path, pack.path, pack.id);
47
+ allFiles.push(...files);
48
+ }
49
+
50
+ const cache = await loadCache();
51
+ let dirty = false;
58
52
 
59
- for (const entry of entries) {
60
- const fullPath = join(dir, entry.name);
61
- if (entry.isDirectory()) {
62
- assets.push(...await walkDir(fullPath, base));
63
- } else if (entry.name.match(/\.(mp3|wav|ogg|m4a)$/i)) {
64
- const relativePath = fullPath.replace(base + '/', '');
65
- const parts = relativePath.split('/');
66
- // For flat dirs (depth 1): icq/uh-oh.mp3 → category=icq, subcategory=''
67
- // For nested dirs (depth 2+): sc2/terran/session-start/scv.mp3 → category=sc2/terran, subcategory=session-start
68
- const category = parts.length > 2 ? parts.slice(0, -2).join('/') : parts[0];
53
+ const results: SoundAsset[] = allFiles.map(({ relPath, absPath }) => {
54
+ const internal = relPath.slice(relPath.indexOf(':') + 1);
55
+ const parts = internal.split('/');
56
+ const packId = relPath.slice(0, relPath.indexOf(':'));
57
+ const category = parts.length > 2
58
+ ? `${packId}:${parts.slice(0, -2).join('/')}`
59
+ : `${packId}:${parts[0]}`;
69
60
  const subcategory = parts.length > 2 ? parts[parts.length - 2] : '';
70
- assets.push({
71
- id: relativePath.replace(/\.[^/.]+$/, ''),
61
+
62
+ const waveform = cache[relPath] ?? (() => {
63
+ const wf = computeWaveform(absPath);
64
+ cache[relPath] = wf;
65
+ dirty = true;
66
+ return wf;
67
+ })();
68
+
69
+ return {
70
+ id: relPath.replace(/\.[^/.]+$/, ''),
72
71
  filename: parts[parts.length - 1],
73
72
  category,
74
73
  subcategory,
75
- path: relativePath,
76
- waveform: FALLBACK,
77
- });
78
- }
79
- }
80
-
81
- return assets;
82
- }
83
-
84
- export async function GET() {
85
- try {
86
- const assets = await walkDir(SOUND_LIBRARY, SOUND_LIBRARY);
87
- const cache = await loadCache();
88
-
89
- let dirty = false;
90
- const results = assets.map((asset) => {
91
- if (cache[asset.path]) {
92
- return { ...asset, waveform: cache[asset.path] };
93
- }
94
- const waveform = computeWaveform(join(SOUND_LIBRARY, asset.path));
95
- cache[asset.path] = waveform;
96
- dirty = true;
97
- return { ...asset, waveform };
74
+ path: relPath,
75
+ waveform,
76
+ };
98
77
  });
99
78
 
100
79
  if (dirty) await saveCache(cache);
101
-
102
80
  return NextResponse.json(results);
103
81
  } catch {
104
82
  return NextResponse.json({ error: 'Failed to read sound library' }, { status: 500 });
@@ -1,44 +1,27 @@
1
1
  import { NextResponse } from 'next/server';
2
- import { readdir, stat } from 'fs/promises';
3
- import { join } from 'path';
4
- import { homedir } from 'os';
5
-
6
- const UI_DIR = join(homedir(), '.agentcraft', 'sounds', 'ui');
7
- const AUDIO_EXTS = new Set(['.mp3', '.wav', '.ogg', '.m4a']);
2
+ import { listPacks, walkPackDir } from '@/lib/packs';
8
3
 
9
4
  interface UISound {
10
- path: string; // relative to ~/.agentcraft/sounds/, e.g. "ui/sc2/click.mp3"
11
- filename: string; // e.g. "click.mp3"
12
- group: string; // e.g. "sc2"
5
+ path: string;
6
+ filename: string;
7
+ group: string;
13
8
  }
14
9
 
15
- async function scanDir(dir: string, base: string): Promise<UISound[]> {
10
+ export async function GET() {
11
+ const packs = await listPacks();
16
12
  const results: UISound[] = [];
17
- let entries: string[];
18
- try {
19
- entries = await readdir(dir);
20
- } catch {
21
- return results;
22
- }
23
- for (const entry of entries.sort()) {
24
- const full = join(dir, entry);
25
- const s = await stat(full).catch(() => null);
26
- if (!s) continue;
27
- if (s.isDirectory()) {
28
- results.push(...await scanDir(full, `${base}/${entry}`));
29
- } else {
30
- const ext = entry.toLowerCase().slice(entry.lastIndexOf('.'));
31
- if (AUDIO_EXTS.has(ext)) {
32
- const rel = `${base}/${entry}`;
33
- const group = base.replace('ui/', '');
34
- results.push({ path: rel, filename: entry, group });
35
- }
13
+
14
+ for (const pack of packs) {
15
+ const uiDir = `${pack.path}/ui`;
16
+ const files = await walkPackDir(uiDir, pack.path, pack.id).catch(() => []);
17
+ for (const { relPath } of files) {
18
+ const internal = relPath.slice(relPath.indexOf(':') + 1);
19
+ const parts = internal.split('/');
20
+ if (parts[0] !== 'ui') continue;
21
+ const group = parts[1] ?? '';
22
+ results.push({ path: relPath, filename: parts[parts.length - 1], group });
36
23
  }
37
24
  }
38
- return results;
39
- }
40
25
 
41
- export async function GET() {
42
- const sounds = await scanDir(UI_DIR, 'ui');
43
- return NextResponse.json(sounds);
26
+ return NextResponse.json(results);
44
27
  }
@@ -3,6 +3,7 @@
3
3
  import { useState, useMemo } from 'react';
4
4
  import { HookSlot } from './hook-slot';
5
5
  import { AgentForm } from './agent-form';
6
+ import { PacksPanel } from './packs-panel';
6
7
  import { playUISound } from '@/lib/ui-audio';
7
8
  import { getEventLabel } from '@/lib/utils';
8
9
  import type { HookEvent, SkillHookEvent, SoundAssignments, AgentInfo, SkillInfo, AgentFormData, SelectMode } from '@/lib/types';
@@ -220,7 +221,7 @@ interface AgentRosterPanelProps {
220
221
  }
221
222
 
222
223
  export function AgentRosterPanel({ assignments, agents, skills, onAssignmentChange, onPreview, onAgentsChange, selectMode, onSlotSelect }: AgentRosterPanelProps) {
223
- const [activeView, setActiveView] = useState<'agents' | 'skills'>('agents');
224
+ const [activeView, setActiveView] = useState<'agents' | 'skills' | 'packs'>('agents');
224
225
  const [showForm, setShowForm] = useState(false);
225
226
  const [editingAgent, setEditingAgent] = useState<AgentInfo | undefined>();
226
227
  const [skillSearch, setSkillSearch] = useState('');
@@ -341,7 +342,7 @@ export function AgentRosterPanel({ assignments, agents, skills, onAssignmentChan
341
342
  {/* Tab bar header */}
342
343
  <div className="shrink-0 border-b" style={{ borderColor: 'var(--sf-border)', backgroundColor: 'var(--sf-panel)' }}>
343
344
  <div className="flex items-stretch">
344
- {(['agents', 'skills'] as const).map((view) => (
345
+ {(['agents', 'skills', 'packs'] as const).map((view) => (
345
346
  <button
346
347
  key={view}
347
348
  data-sf-hover
@@ -359,7 +360,7 @@ export function AgentRosterPanel({ assignments, agents, skills, onAssignmentChan
359
360
  backgroundColor: activeView === view ? 'rgba(0,229,255,0.04)' : 'transparent',
360
361
  }}
361
362
  >
362
- {view === 'agents' ? 'AGENTS' : 'SKILLS'}
363
+ {view.toUpperCase()}
363
364
  </button>
364
365
  ))}
365
366
  {activeView === 'agents' && (
@@ -496,6 +497,8 @@ export function AgentRosterPanel({ assignments, agents, skills, onAssignmentChan
496
497
  })}
497
498
  </>
498
499
  )}
500
+
501
+ {activeView === 'packs' && <PacksPanel />}
499
502
  </div>
500
503
  </div>
501
504
  );