stagent 0.1.0 → 0.1.1

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 (64) hide show
  1. package/README.md +33 -30
  2. package/dist/cli.js +376 -49
  3. package/package.json +20 -21
  4. package/public/desktop-icon-512.png +0 -0
  5. package/public/icon-512.png +0 -0
  6. package/src/app/api/data/clear/route.ts +0 -7
  7. package/src/app/api/data/seed/route.ts +0 -7
  8. package/src/app/api/profiles/[id]/context/route.ts +109 -0
  9. package/src/components/dashboard/__tests__/accessibility.test.tsx +42 -0
  10. package/src/components/documents/__tests__/document-upload-dialog.test.tsx +46 -0
  11. package/src/components/notifications/__tests__/pending-approval-host.test.tsx +122 -0
  12. package/src/components/notifications/__tests__/permission-response-actions.test.tsx +79 -0
  13. package/src/components/notifications/pending-approval-host.tsx +49 -25
  14. package/src/components/profiles/context-proposal-review.tsx +145 -0
  15. package/src/components/profiles/learned-context-panel.tsx +286 -0
  16. package/src/components/profiles/profile-detail-view.tsx +4 -0
  17. package/src/components/projects/__tests__/dialog-focus.test.tsx +87 -0
  18. package/src/components/tasks/__tests__/kanban-board-accessibility.test.tsx +59 -0
  19. package/src/lib/__tests__/setup-verify.test.ts +28 -0
  20. package/src/lib/__tests__/utils.test.ts +29 -0
  21. package/src/lib/agents/__tests__/claude-agent.test.ts +946 -0
  22. package/src/lib/agents/__tests__/execution-manager.test.ts +63 -0
  23. package/src/lib/agents/__tests__/router.test.ts +61 -0
  24. package/src/lib/agents/claude-agent.ts +34 -5
  25. package/src/lib/agents/learned-context.ts +322 -0
  26. package/src/lib/agents/pattern-extractor.ts +150 -0
  27. package/src/lib/agents/profiles/__tests__/compatibility.test.ts +76 -0
  28. package/src/lib/agents/profiles/__tests__/registry.test.ts +177 -0
  29. package/src/lib/agents/profiles/builtins/sweep/SKILL.md +47 -0
  30. package/src/lib/agents/profiles/builtins/sweep/profile.yaml +12 -0
  31. package/src/lib/agents/runtime/__tests__/catalog.test.ts +38 -0
  32. package/src/lib/agents/runtime/openai-codex.ts +1 -1
  33. package/src/lib/agents/sweep.ts +65 -0
  34. package/src/lib/constants/__tests__/task-status.test.ts +119 -0
  35. package/src/lib/data/seed-data/__tests__/profiles.test.ts +141 -0
  36. package/src/lib/db/__tests__/bootstrap.test.ts +56 -0
  37. package/src/lib/db/bootstrap.ts +301 -0
  38. package/src/lib/db/index.ts +2 -205
  39. package/src/lib/db/migrations/0004_add_documents.sql +2 -1
  40. package/src/lib/db/migrations/0005_add_document_preprocessing.sql +2 -0
  41. package/src/lib/db/migrations/0006_add_agent_profile.sql +1 -0
  42. package/src/lib/db/migrations/0007_add_usage_metering_ledger.sql +9 -2
  43. package/src/lib/db/migrations/meta/_journal.json +43 -1
  44. package/src/lib/db/schema.ts +34 -0
  45. package/src/lib/desktop/__tests__/sidecar-launch.test.ts +70 -0
  46. package/src/lib/desktop/sidecar-launch.ts +85 -0
  47. package/src/lib/documents/__tests__/context-builder.test.ts +57 -0
  48. package/src/lib/documents/__tests__/output-scanner.test.ts +141 -0
  49. package/src/lib/notifications/actionable.ts +21 -7
  50. package/src/lib/settings/__tests__/auth.test.ts +220 -0
  51. package/src/lib/settings/__tests__/budget-guardrails.test.ts +181 -0
  52. package/src/lib/tauri-bridge.ts +138 -0
  53. package/src/lib/usage/__tests__/ledger.test.ts +284 -0
  54. package/src/lib/utils/__tests__/crypto.test.ts +90 -0
  55. package/src/lib/validators/__tests__/profile.test.ts +119 -0
  56. package/src/lib/validators/__tests__/project.test.ts +82 -0
  57. package/src/lib/validators/__tests__/settings.test.ts +151 -0
  58. package/src/lib/validators/__tests__/task.test.ts +144 -0
  59. package/src/lib/workflows/__tests__/definition-validation.test.ts +164 -0
  60. package/src/lib/workflows/__tests__/engine.test.ts +114 -0
  61. package/src/lib/workflows/__tests__/loop-executor.test.ts +54 -0
  62. package/src/lib/workflows/__tests__/parallel.test.ts +75 -0
  63. package/src/lib/workflows/__tests__/swarm.test.ts +97 -0
  64. package/src/test/setup.ts +10 -0
package/README.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  > A governed AI agent operations workspace for running, supervising, and reusing AI work through projects, workflows, documents, profiles, schedules, inbox approvals, and live monitoring.
4
4
 
5
+ [![Download macOS Desktop](https://img.shields.io/badge/Download-macOS_Desktop-0a7cff?style=for-the-badge&logo=apple)](https://github.com/navam-io/stagent/releases/latest/download/Stagent.dmg) [![GitHub Releases](https://img.shields.io/github/v/release/navam-io/stagent?display_name=release&style=for-the-badge)](https://github.com/navam-io/stagent/releases/latest)
6
+
5
7
  [![Next.js 16](https://img.shields.io/badge/Next.js-16-black)](https://nextjs.org/) [![React 19](https://img.shields.io/badge/React-19-61DAFB)](https://react.dev/) [![TypeScript](https://img.shields.io/badge/TypeScript-strict-3178C6)](https://www.typescriptlang.org/) [![Claude Agent SDK](https://img.shields.io/badge/Claude-Agent_SDK-D97706)](https://docs.anthropic.com/) [![OpenAI Codex App Server](https://img.shields.io/badge/OpenAI-Codex_App_Server-10A37F)](https://developers.openai.com/codex/app-server) [![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](LICENSE)
6
8
 
7
9
  ## Why Stagent
@@ -10,34 +12,21 @@ AI agents are powerful, but production deployment breaks down when teams cannot
10
12
 
11
13
  Stagent is a local-first AI operations workspace built around governed execution and reusable automation primitives. Instead of treating every agent run as a one-off prompt, it gives teams a structured system of home workspace signals, execution dashboards, project context, workflow blueprints, reusable profiles, schedules, documents, inbox approvals, and live monitoring.
12
14
 
13
- ## Quick Start
15
+ ## Get Started
14
16
 
15
- ### NPX install
17
+ ### Download Desktop for macOS
16
18
 
17
- Requires Node.js 20 or newer.
19
+ **Primary install path:** [Download the latest macOS desktop release](https://github.com/navam-io/stagent/releases/latest/download/Stagent.dmg)
18
20
 
19
- ```bash
20
- export ANTHROPIC_API_KEY=your-anthropic-key
21
- export OPENAI_API_KEY=your-openai-key
21
+ Grab the latest `Stagent.dmg` or `Stagent.app.zip` asset from GitHub Releases, then move `Stagent.app` into `/Applications`.
22
22
 
23
- npx stagent
24
- ```
23
+ Current desktop release notes:
25
24
 
26
- Useful variants:
25
+ - macOS-only for now
26
+ - GitHub desktop releases are intended to be Developer ID signed and notarized; local smoke builds created with `npm run desktop:release -- --skip-upload` will still warn if Apple credentials are not configured
27
+ - the current desktop wrapper expects `node` to be available on the machine
27
28
 
28
- ```bash
29
- npx stagent --port 3210
30
- npx stagent --no-open
31
- npm install -g stagent && stagent
32
- ```
33
-
34
- Stagent stores local state in `~/.stagent/` by default. To isolate a run or smoke-test the packaged app, override the data directory:
35
-
36
- ```bash
37
- STAGENT_DATA_DIR=/tmp/stagent-smoke npx stagent --reset --no-open
38
- ```
39
-
40
- <img src="https://unpkg.com/stagent@latest/public/readme/home-workspace.png" alt="Stagent home workspace" width="1200" />
29
+ <img src="./output/playwright/home-playwright-after.png" alt="Stagent home workspace" width="1200" />
41
30
 
42
31
  ### Repository development
43
32
 
@@ -228,7 +217,10 @@ File upload with drag-and-drop in task creation. Type-aware content preview for
228
217
  Configuration hub with provider-aware sections: Claude authentication (API key or OAuth), OpenAI Codex runtime API-key management, tool permissions (saved "Always Allow" patterns with revoke), and data management.
229
218
 
230
219
  #### CLI
231
- `npx stagent` launches the dev server and opens the dashboard. Built with Commander, compiled via tsup to `dist/cli.js`. The CLI supports `--port`, `--reset`, `--no-open`, and `STAGENT_DATA_DIR` for isolated packaged runs.
220
+ Stagent still uses a small Node sidecar under the hood. It is built from `bin/cli.ts` into `dist/cli.js`, but it is now an internal desktop bootstrap rather than a user-facing distribution channel.
221
+
222
+ #### Tauri Desktop
223
+ The repo produces macOS desktop artifacts via Tauri. Local development uses `npm run desktop:dev`, local packaging uses `npm run desktop:build`, and release publishing now runs locally via `npm run desktop:release` so the uploaded GitHub assets stay on stable names: `Stagent.dmg` and `Stagent.app.zip`. Published releases must be built with `APPLE_SIGNING_IDENTITY` plus notarization credentials so the downloaded app clears Gatekeeper without the "Apple could not verify" malware warning. Keep `public/desktop-icon-512.png` as the dedicated rounded desktop source icon with transparent corners; `public/icon-512.png` remains the square web/PWA icon. The local release script also stamps the same branded icon onto the mounted DMG volume so Finder shows the corrected Stagent icon during install.
232
224
 
233
225
  #### Database
234
226
  SQLite with WAL mode via better-sqlite3 + Drizzle ORM. Eight tables: `projects`, `tasks`, `workflows`, `agent_logs`, `notifications`, `documents`, `schedules`, `settings`. Self-healing bootstrap — tables are created on startup if missing.
@@ -261,21 +253,32 @@ Responsive sidebar with collapsible icon-only mode, custom Stagent logo, tooltip
261
253
  ```bash
262
254
  npm run dev # Next.js dev server (Turbopack)
263
255
  npm run build:cli # Build CLI → dist/cli.js
264
- npm run smoke:npm # Pack tarball and launch the packaged CLI from a temp install
265
256
  npm test # Run Vitest
266
257
  npm run test:coverage # Coverage report
267
258
  ```
268
259
 
269
- ## NPM Release Checklist
260
+ ## Desktop Release Checklist
261
+
262
+ ```bash
263
+ npm run desktop:release
264
+ ```
265
+
266
+ For a build-only smoke check, run `npm run desktop:release -- --skip-upload`.
267
+
268
+ Before publishing a real desktop release, configure Apple signing once on the release machine:
270
269
 
271
270
  ```bash
272
- npm version <patch|minor|major>
273
- npm run build:cli
274
- npm run smoke:npm
275
- npm publish
271
+ export APPLE_SIGNING_IDENTITY="Developer ID Application: Your Name (TEAMID)"
272
+ xcrun notarytool store-credentials stagent-notary \
273
+ --apple-id "you@example.com" \
274
+ --team-id "TEAMID" \
275
+ --password "app-specific-password"
276
+ export APPLE_NOTARY_PROFILE="stagent-notary"
276
277
  ```
277
278
 
278
- `npm run smoke:npm` builds the CLI, creates a real npm tarball with `npm pack`, extracts it into a temporary hoisted-layout install, launches the packaged app with `--no-open`, verifies data-directory bootstrap plus server startup, and then cleans up.
279
+ You can use direct credentials instead of a keychain profile by exporting `APPLE_ID`, `APPLE_APP_SPECIFIC_PASSWORD`, and `APPLE_TEAM_ID`.
280
+
281
+ The release script builds locally on macOS, signs the app, notarizes and staples the app bundle and DMG when Apple credentials are configured, verifies the DMG, creates or updates the `desktop-v<package.json version>` GitHub release, uploads `Stagent.dmg` and `Stagent.app.zip`, and refreshes the stable download URL at `https://github.com/navam-io/stagent/releases/latest/download/Stagent.dmg`. Uploads now fail fast unless Developer ID signing and notarization are configured so unsigned artifacts are not published by accident.
279
282
 
280
283
  ### Project Structure
281
284
 
package/dist/cli.js CHANGED
@@ -2,11 +2,11 @@
2
2
 
3
3
  // bin/cli.ts
4
4
  import { program } from "commander";
5
- import { dirname, join as join2 } from "path";
5
+ import { dirname as dirname2, join as join3 } from "path";
6
6
  import { fileURLToPath } from "url";
7
7
  import {
8
8
  mkdirSync,
9
- existsSync,
9
+ existsSync as existsSync2,
10
10
  readFileSync,
11
11
  writeFileSync,
12
12
  cpSync,
@@ -18,54 +18,372 @@ import Database from "better-sqlite3";
18
18
  import { drizzle } from "drizzle-orm/better-sqlite3";
19
19
  import { migrate } from "drizzle-orm/better-sqlite3/migrator";
20
20
 
21
+ // src/lib/desktop/sidecar-launch.ts
22
+ import { existsSync } from "fs";
23
+ import { dirname, join } from "path";
24
+ var SIDECAR_LOOPBACK_HOST = "127.0.0.1";
25
+ function wasPortExplicitlyRequested(argv) {
26
+ return argv.some(
27
+ (argument) => argument === "-p" || argument === "--port" || argument.startsWith("--port=")
28
+ );
29
+ }
30
+ async function resolveSidecarPort({
31
+ argv,
32
+ requestedPort: requestedPort2,
33
+ findAvailablePort: findAvailablePort2
34
+ }) {
35
+ if (wasPortExplicitlyRequested(argv)) {
36
+ return requestedPort2;
37
+ }
38
+ return findAvailablePort2(requestedPort2);
39
+ }
40
+ function findClosestPath(cwd, segments, exists = existsSync) {
41
+ let dir = cwd;
42
+ while (true) {
43
+ const candidate = join(dir, ...segments);
44
+ if (exists(candidate)) {
45
+ return candidate;
46
+ }
47
+ const parent = dirname(dir);
48
+ if (parent === dir) {
49
+ return null;
50
+ }
51
+ dir = parent;
52
+ }
53
+ }
54
+ function resolveNextEntrypoint(cwd, exists = existsSync) {
55
+ const nextEntrypoint = findClosestPath(cwd, ["node_modules", "next", "dist", "bin", "next"], exists);
56
+ if (nextEntrypoint) {
57
+ return nextEntrypoint;
58
+ }
59
+ throw new Error(
60
+ `Could not resolve Next.js CLI entrypoint from ${cwd}. Expected node_modules/next/dist/bin/next.`
61
+ );
62
+ }
63
+ function buildNextLaunchArgs({
64
+ isPrebuilt,
65
+ port,
66
+ host = SIDECAR_LOOPBACK_HOST
67
+ }) {
68
+ if (isPrebuilt) {
69
+ return ["start", "--hostname", host, "--port", String(port)];
70
+ }
71
+ return ["dev", "--turbopack", "--hostname", host, "--port", String(port)];
72
+ }
73
+ function buildSidecarUrl(port, host = SIDECAR_LOOPBACK_HOST) {
74
+ return `http://${host}:${port}`;
75
+ }
76
+
21
77
  // src/lib/utils/stagent-paths.ts
22
78
  import { homedir } from "os";
23
- import { join } from "path";
79
+ import { join as join2 } from "path";
24
80
  function getStagentDataDir() {
25
- return process.env.STAGENT_DATA_DIR || join(homedir(), ".stagent");
81
+ return process.env.STAGENT_DATA_DIR || join2(homedir(), ".stagent");
26
82
  }
27
83
  function getStagentDbPath() {
28
- return join(getStagentDataDir(), "stagent.db");
84
+ return join2(getStagentDataDir(), "stagent.db");
85
+ }
86
+
87
+ // src/lib/db/bootstrap.ts
88
+ import { readMigrationFiles } from "drizzle-orm/migrator";
89
+ var STAGENT_TABLES = [
90
+ "projects",
91
+ "tasks",
92
+ "workflows",
93
+ "agent_logs",
94
+ "notifications",
95
+ "settings",
96
+ "documents",
97
+ "schedules",
98
+ "usage_ledger",
99
+ "learned_context"
100
+ ];
101
+ function bootstrapStagentDatabase(sqlite2) {
102
+ sqlite2.exec(`
103
+ CREATE TABLE IF NOT EXISTS projects (
104
+ id TEXT PRIMARY KEY NOT NULL,
105
+ name TEXT NOT NULL,
106
+ description TEXT,
107
+ status TEXT DEFAULT 'active' NOT NULL,
108
+ created_at INTEGER NOT NULL,
109
+ updated_at INTEGER NOT NULL
110
+ );
111
+
112
+ CREATE TABLE IF NOT EXISTS tasks (
113
+ id TEXT PRIMARY KEY NOT NULL,
114
+ project_id TEXT,
115
+ workflow_id TEXT,
116
+ schedule_id TEXT,
117
+ title TEXT NOT NULL,
118
+ description TEXT,
119
+ status TEXT DEFAULT 'planned' NOT NULL,
120
+ assigned_agent TEXT,
121
+ agent_profile TEXT,
122
+ priority INTEGER DEFAULT 2 NOT NULL,
123
+ result TEXT,
124
+ session_id TEXT,
125
+ resume_count INTEGER DEFAULT 0 NOT NULL,
126
+ created_at INTEGER NOT NULL,
127
+ updated_at INTEGER NOT NULL,
128
+ FOREIGN KEY (project_id) REFERENCES projects(id) ON UPDATE NO ACTION ON DELETE NO ACTION,
129
+ FOREIGN KEY (workflow_id) REFERENCES workflows(id) ON UPDATE NO ACTION ON DELETE NO ACTION,
130
+ FOREIGN KEY (schedule_id) REFERENCES schedules(id) ON UPDATE NO ACTION ON DELETE NO ACTION
131
+ );
132
+
133
+ CREATE TABLE IF NOT EXISTS workflows (
134
+ id TEXT PRIMARY KEY NOT NULL,
135
+ project_id TEXT,
136
+ name TEXT NOT NULL,
137
+ definition TEXT NOT NULL,
138
+ status TEXT DEFAULT 'draft' NOT NULL,
139
+ created_at INTEGER NOT NULL,
140
+ updated_at INTEGER NOT NULL,
141
+ FOREIGN KEY (project_id) REFERENCES projects(id) ON UPDATE NO ACTION ON DELETE NO ACTION
142
+ );
143
+
144
+ CREATE TABLE IF NOT EXISTS agent_logs (
145
+ id TEXT PRIMARY KEY NOT NULL,
146
+ task_id TEXT,
147
+ agent_type TEXT NOT NULL,
148
+ event TEXT NOT NULL,
149
+ payload TEXT,
150
+ timestamp INTEGER NOT NULL,
151
+ FOREIGN KEY (task_id) REFERENCES tasks(id) ON UPDATE NO ACTION ON DELETE NO ACTION
152
+ );
153
+
154
+ CREATE TABLE IF NOT EXISTS notifications (
155
+ id TEXT PRIMARY KEY NOT NULL,
156
+ task_id TEXT,
157
+ type TEXT NOT NULL,
158
+ title TEXT NOT NULL,
159
+ body TEXT,
160
+ read INTEGER DEFAULT 0 NOT NULL,
161
+ tool_name TEXT,
162
+ tool_input TEXT,
163
+ response TEXT,
164
+ responded_at INTEGER,
165
+ created_at INTEGER NOT NULL,
166
+ FOREIGN KEY (task_id) REFERENCES tasks(id) ON UPDATE NO ACTION ON DELETE NO ACTION
167
+ );
168
+
169
+ CREATE TABLE IF NOT EXISTS settings (
170
+ key TEXT PRIMARY KEY,
171
+ value TEXT NOT NULL,
172
+ updated_at INTEGER NOT NULL
173
+ );
174
+
175
+ CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
176
+ CREATE INDEX IF NOT EXISTS idx_tasks_project_id ON tasks(project_id);
177
+ CREATE INDEX IF NOT EXISTS idx_agent_logs_task_id ON agent_logs(task_id);
178
+ CREATE INDEX IF NOT EXISTS idx_agent_logs_timestamp ON agent_logs(timestamp);
179
+
180
+ CREATE TABLE IF NOT EXISTS documents (
181
+ id TEXT PRIMARY KEY NOT NULL,
182
+ task_id TEXT,
183
+ project_id TEXT,
184
+ filename TEXT NOT NULL,
185
+ original_name TEXT NOT NULL,
186
+ mime_type TEXT NOT NULL,
187
+ size INTEGER NOT NULL,
188
+ storage_path TEXT NOT NULL,
189
+ version INTEGER DEFAULT 1 NOT NULL,
190
+ direction TEXT DEFAULT 'input' NOT NULL,
191
+ category TEXT,
192
+ status TEXT DEFAULT 'uploaded' NOT NULL,
193
+ extracted_text TEXT,
194
+ processed_path TEXT,
195
+ processing_error TEXT,
196
+ created_at INTEGER NOT NULL,
197
+ updated_at INTEGER NOT NULL,
198
+ FOREIGN KEY (task_id) REFERENCES tasks(id) ON UPDATE NO ACTION ON DELETE NO ACTION,
199
+ FOREIGN KEY (project_id) REFERENCES projects(id) ON UPDATE NO ACTION ON DELETE NO ACTION
200
+ );
201
+
202
+ CREATE TABLE IF NOT EXISTS schedules (
203
+ id TEXT PRIMARY KEY NOT NULL,
204
+ project_id TEXT,
205
+ name TEXT NOT NULL,
206
+ prompt TEXT NOT NULL,
207
+ cron_expression TEXT NOT NULL,
208
+ assigned_agent TEXT,
209
+ agent_profile TEXT,
210
+ recurs INTEGER DEFAULT 1 NOT NULL,
211
+ status TEXT DEFAULT 'active' NOT NULL,
212
+ max_firings INTEGER,
213
+ firing_count INTEGER DEFAULT 0 NOT NULL,
214
+ expires_at INTEGER,
215
+ last_fired_at INTEGER,
216
+ next_fire_at INTEGER,
217
+ created_at INTEGER NOT NULL,
218
+ updated_at INTEGER NOT NULL,
219
+ FOREIGN KEY (project_id) REFERENCES projects(id) ON UPDATE NO ACTION ON DELETE NO ACTION
220
+ );
221
+
222
+ CREATE INDEX IF NOT EXISTS idx_schedules_status ON schedules(status);
223
+ CREATE INDEX IF NOT EXISTS idx_schedules_next_fire_at ON schedules(next_fire_at);
224
+ CREATE INDEX IF NOT EXISTS idx_schedules_project_id ON schedules(project_id);
225
+
226
+ CREATE INDEX IF NOT EXISTS idx_notifications_task_id ON notifications(task_id);
227
+ CREATE INDEX IF NOT EXISTS idx_notifications_read ON notifications(read);
228
+ CREATE INDEX IF NOT EXISTS idx_documents_task_id ON documents(task_id);
229
+ CREATE INDEX IF NOT EXISTS idx_documents_project_id ON documents(project_id);
230
+
231
+ CREATE TABLE IF NOT EXISTS usage_ledger (
232
+ id TEXT PRIMARY KEY NOT NULL,
233
+ task_id TEXT,
234
+ workflow_id TEXT,
235
+ schedule_id TEXT,
236
+ project_id TEXT,
237
+ activity_type TEXT NOT NULL,
238
+ runtime_id TEXT NOT NULL,
239
+ provider_id TEXT NOT NULL,
240
+ model_id TEXT,
241
+ status TEXT NOT NULL,
242
+ input_tokens INTEGER,
243
+ output_tokens INTEGER,
244
+ total_tokens INTEGER,
245
+ cost_micros INTEGER,
246
+ pricing_version TEXT,
247
+ started_at INTEGER NOT NULL,
248
+ finished_at INTEGER NOT NULL,
249
+ FOREIGN KEY (task_id) REFERENCES tasks(id) ON UPDATE NO ACTION ON DELETE NO ACTION,
250
+ FOREIGN KEY (workflow_id) REFERENCES workflows(id) ON UPDATE NO ACTION ON DELETE NO ACTION,
251
+ FOREIGN KEY (schedule_id) REFERENCES schedules(id) ON UPDATE NO ACTION ON DELETE NO ACTION,
252
+ FOREIGN KEY (project_id) REFERENCES projects(id) ON UPDATE NO ACTION ON DELETE NO ACTION
253
+ );
254
+
255
+ CREATE INDEX IF NOT EXISTS idx_usage_ledger_task_id ON usage_ledger(task_id);
256
+ CREATE INDEX IF NOT EXISTS idx_usage_ledger_activity_type ON usage_ledger(activity_type);
257
+ CREATE INDEX IF NOT EXISTS idx_usage_ledger_runtime_id ON usage_ledger(runtime_id);
258
+ CREATE INDEX IF NOT EXISTS idx_usage_ledger_provider_model ON usage_ledger(provider_id, model_id);
259
+ CREATE INDEX IF NOT EXISTS idx_usage_ledger_finished_at ON usage_ledger(finished_at);
260
+
261
+ CREATE TABLE IF NOT EXISTS learned_context (
262
+ id TEXT PRIMARY KEY NOT NULL,
263
+ profile_id TEXT NOT NULL,
264
+ version INTEGER NOT NULL,
265
+ content TEXT,
266
+ diff TEXT,
267
+ change_type TEXT NOT NULL,
268
+ source_task_id TEXT,
269
+ proposal_notification_id TEXT,
270
+ proposed_additions TEXT,
271
+ approved_by TEXT,
272
+ created_at INTEGER NOT NULL,
273
+ FOREIGN KEY (source_task_id) REFERENCES tasks(id) ON UPDATE NO ACTION ON DELETE NO ACTION
274
+ );
275
+
276
+ CREATE INDEX IF NOT EXISTS idx_learned_context_profile_version ON learned_context(profile_id, version);
277
+ CREATE INDEX IF NOT EXISTS idx_learned_context_change_type ON learned_context(change_type);
278
+ `);
279
+ try {
280
+ sqlite2.exec(`ALTER TABLE tasks ADD COLUMN agent_profile TEXT;`);
281
+ } catch {
282
+ }
283
+ sqlite2.exec(`CREATE INDEX IF NOT EXISTS idx_tasks_agent_profile ON tasks(agent_profile);`);
284
+ try {
285
+ sqlite2.exec(`ALTER TABLE tasks ADD COLUMN workflow_id TEXT REFERENCES workflows(id);`);
286
+ } catch {
287
+ }
288
+ sqlite2.exec(`CREATE INDEX IF NOT EXISTS idx_tasks_workflow_id ON tasks(workflow_id);`);
289
+ try {
290
+ sqlite2.exec(`ALTER TABLE tasks ADD COLUMN schedule_id TEXT REFERENCES schedules(id);`);
291
+ } catch {
292
+ }
293
+ sqlite2.exec(`CREATE INDEX IF NOT EXISTS idx_tasks_schedule_id ON tasks(schedule_id);`);
294
+ try {
295
+ sqlite2.exec(`ALTER TABLE projects ADD COLUMN working_directory TEXT;`);
296
+ } catch {
297
+ }
298
+ try {
299
+ sqlite2.exec(`ALTER TABLE schedules ADD COLUMN assigned_agent TEXT;`);
300
+ } catch {
301
+ }
302
+ try {
303
+ sqlite2.exec(`ALTER TABLE documents ADD COLUMN version INTEGER NOT NULL DEFAULT 1;`);
304
+ } catch {
305
+ }
306
+ }
307
+ function hasLegacyStagentTables(sqlite2) {
308
+ const placeholders = STAGENT_TABLES.map(() => "?").join(", ");
309
+ const row = sqlite2.prepare(
310
+ `SELECT COUNT(*) AS count
311
+ FROM sqlite_master
312
+ WHERE type = 'table' AND name IN (${placeholders})`
313
+ ).get(...STAGENT_TABLES);
314
+ return row.count > 0;
315
+ }
316
+ function hasMigrationHistory(sqlite2, migrationsTable = "__drizzle_migrations") {
317
+ const tableRow = sqlite2.prepare(
318
+ `SELECT COUNT(*) AS count
319
+ FROM sqlite_master
320
+ WHERE type = 'table' AND name = ?`
321
+ ).get(migrationsTable);
322
+ if (tableRow.count === 0) {
323
+ return false;
324
+ }
325
+ const row = sqlite2.prepare(`SELECT COUNT(*) AS count FROM ${migrationsTable}`).get();
326
+ return row.count > 0;
327
+ }
328
+ function markAllMigrationsApplied(sqlite2, migrationsFolder, migrationsTable = "__drizzle_migrations") {
329
+ const migrations = readMigrationFiles({ migrationsFolder, migrationsTable });
330
+ sqlite2.exec(`
331
+ CREATE TABLE IF NOT EXISTS ${migrationsTable} (
332
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
333
+ hash TEXT NOT NULL,
334
+ created_at NUMERIC
335
+ )
336
+ `);
337
+ const existing = sqlite2.prepare(`SELECT hash, created_at FROM ${migrationsTable}`).all();
338
+ const seen = new Set(existing.map((row) => `${row.hash}:${row.created_at}`));
339
+ const insert = sqlite2.prepare(
340
+ `INSERT INTO ${migrationsTable} (hash, created_at) VALUES (?, ?)`
341
+ );
342
+ for (const migration of migrations) {
343
+ const key = `${migration.hash}:${migration.folderMillis}`;
344
+ if (!seen.has(key)) {
345
+ insert.run(migration.hash, migration.folderMillis);
346
+ }
347
+ }
29
348
  }
30
349
 
31
350
  // bin/cli.ts
32
- var __dirname = dirname(fileURLToPath(import.meta.url));
33
- var appDir = join2(__dirname, "..");
351
+ var __dirname = dirname2(fileURLToPath(import.meta.url));
352
+ var appDir = join3(__dirname, "..");
34
353
  var DATA_DIR = getStagentDataDir();
35
354
  var dbPath = getStagentDbPath();
36
- var pkg = JSON.parse(readFileSync(join2(appDir, "package.json"), "utf-8"));
355
+ var pkg = JSON.parse(readFileSync(join3(appDir, "package.json"), "utf-8"));
37
356
  var HELP_TEXT = `
38
357
  Data:
39
358
  Directory ${DATA_DIR}
40
359
  Database ${dbPath}
41
- Sessions ${join2(DATA_DIR, "sessions")}
42
- Logs ${join2(DATA_DIR, "logs")}
360
+ Sessions ${join3(DATA_DIR, "sessions")}
361
+ Logs ${join3(DATA_DIR, "logs")}
43
362
 
44
363
  Environment variables:
45
- STAGENT_DATA_DIR Custom data directory for the CLI and web app
364
+ STAGENT_DATA_DIR Custom data directory for the desktop sidecar and web app
46
365
  ANTHROPIC_API_KEY Claude runtime access
47
366
  OPENAI_API_KEY OpenAI Codex runtime access
48
367
 
49
368
  Examples:
50
- npx stagent
51
- npx stagent --port 3210 --no-open
52
- STAGENT_DATA_DIR=/tmp/stagent-smoke npx stagent --reset
369
+ node dist/cli.js --port 3210 --no-open
370
+ STAGENT_DATA_DIR=/tmp/stagent-desktop node dist/cli.js --reset
53
371
  `;
54
- program.name("stagent").description("Governed AI agent workspace \u2014 local-first").version(pkg.version).addHelpText("after", HELP_TEXT).option("-p, --port <number>", "port to start on", "3000").option("--reset", "delete the local database before starting").option("--no-open", "don't auto-open browser").parse();
372
+ program.name("stagent").description("Governed desktop AI agent workspace").version(pkg.version).addHelpText("after", HELP_TEXT).option("-p, --port <number>", "port to start on", "3000").option("--reset", "delete the local database before starting").option("--no-open", "don't auto-open browser").parse();
55
373
  var opts = program.opts();
56
374
  var requestedPort = Number.parseInt(opts.port, 10);
57
375
  if (Number.isNaN(requestedPort) || requestedPort <= 0) {
58
376
  program.error(`Invalid port: ${opts.port}`);
59
377
  }
60
- for (const dir of [DATA_DIR, join2(DATA_DIR, "logs"), join2(DATA_DIR, "sessions")]) {
378
+ for (const dir of [DATA_DIR, join3(DATA_DIR, "logs"), join3(DATA_DIR, "sessions")]) {
61
379
  mkdirSync(dir, { recursive: true });
62
380
  }
63
381
  if (opts.reset) {
64
- if (existsSync(dbPath)) {
382
+ if (existsSync2(dbPath)) {
65
383
  unlinkSync(dbPath);
66
384
  for (const suffix of ["-wal", "-shm"]) {
67
385
  const filePath = dbPath + suffix;
68
- if (existsSync(filePath)) unlinkSync(filePath);
386
+ if (existsSync2(filePath)) unlinkSync(filePath);
69
387
  }
70
388
  console.log("Database reset.");
71
389
  } else {
@@ -75,9 +393,17 @@ if (opts.reset) {
75
393
  var sqlite = new Database(dbPath);
76
394
  sqlite.pragma("journal_mode = WAL");
77
395
  sqlite.pragma("foreign_keys = ON");
396
+ var migrationsDir = join3(appDir, "src", "lib", "db", "migrations");
78
397
  var db = drizzle(sqlite);
79
- var migrationsDir = join2(appDir, "src", "lib", "db", "migrations");
80
- migrate(db, { migrationsFolder: migrationsDir });
398
+ var needsLegacyRecovery = hasLegacyStagentTables(sqlite) && !hasMigrationHistory(sqlite);
399
+ if (needsLegacyRecovery) {
400
+ bootstrapStagentDatabase(sqlite);
401
+ markAllMigrationsApplied(sqlite, migrationsDir);
402
+ console.log("Recovered legacy database schema.");
403
+ } else {
404
+ migrate(db, { migrationsFolder: migrationsDir });
405
+ bootstrapStagentDatabase(sqlite);
406
+ }
81
407
  sqlite.close();
82
408
  console.log("Database ready.");
83
409
  function findAvailablePort(preferred) {
@@ -91,31 +417,24 @@ function findAvailablePort(preferred) {
91
417
  });
92
418
  });
93
419
  }
94
- function findLocalBin(name, cwd) {
95
- const local = join2(cwd, "node_modules", ".bin", name);
96
- if (existsSync(local)) return local;
97
- let dir = dirname(cwd);
98
- while (dir !== dirname(dir)) {
99
- const candidate = join2(dir, "node_modules", ".bin", name);
100
- if (existsSync(candidate)) return candidate;
101
- dir = dirname(dir);
102
- }
103
- return name;
104
- }
105
420
  async function main() {
106
- const actualPort = await findAvailablePort(requestedPort);
421
+ const actualPort = await resolveSidecarPort({
422
+ argv: process.argv.slice(2),
423
+ requestedPort,
424
+ findAvailablePort
425
+ });
107
426
  let effectiveCwd = appDir;
108
- const localNm = join2(appDir, "node_modules");
109
- if (!existsSync(join2(localNm, "next", "package.json"))) {
110
- let searchDir = dirname(appDir);
111
- while (searchDir !== dirname(searchDir)) {
112
- const candidate = join2(searchDir, "node_modules", "next", "package.json");
113
- if (existsSync(candidate)) {
427
+ const localNm = join3(appDir, "node_modules");
428
+ if (!existsSync2(join3(localNm, "next", "package.json"))) {
429
+ let searchDir = dirname2(appDir);
430
+ while (searchDir !== dirname2(searchDir)) {
431
+ const candidate = join3(searchDir, "node_modules", "next", "package.json");
432
+ if (existsSync2(candidate)) {
114
433
  const hoistedRoot = searchDir;
115
434
  for (const name of ["src", "public"]) {
116
- const dest = join2(hoistedRoot, name);
117
- const src = join2(appDir, name);
118
- if (!existsSync(dest) && existsSync(src)) {
435
+ const dest = join3(hoistedRoot, name);
436
+ const src = join3(appDir, name);
437
+ if (!existsSync2(dest) && existsSync2(src)) {
119
438
  cpSync(src, dest, { recursive: true });
120
439
  }
121
440
  }
@@ -127,23 +446,31 @@ async function main() {
127
446
  "components.json",
128
447
  "drizzle.config.ts"
129
448
  ]) {
130
- const dest = join2(hoistedRoot, name);
131
- const src = join2(appDir, name);
132
- if (!existsSync(dest) && existsSync(src)) {
449
+ const dest = join3(hoistedRoot, name);
450
+ const src = join3(appDir, name);
451
+ if (!existsSync2(dest) && existsSync2(src)) {
133
452
  writeFileSync(dest, readFileSync(src));
134
453
  }
135
454
  }
136
455
  effectiveCwd = hoistedRoot;
137
456
  break;
138
457
  }
139
- searchDir = dirname(searchDir);
458
+ searchDir = dirname2(searchDir);
140
459
  }
141
460
  }
142
- const nextBin = findLocalBin("next", effectiveCwd);
461
+ const nextEntrypoint = resolveNextEntrypoint(effectiveCwd);
462
+ const isPrebuilt = existsSync2(join3(effectiveCwd, ".next", "BUILD_ID"));
463
+ const nextArgs = buildNextLaunchArgs({
464
+ isPrebuilt,
465
+ port: actualPort
466
+ });
467
+ const sidecarUrl = buildSidecarUrl(actualPort);
143
468
  console.log(`Stagent ${pkg.version}`);
144
469
  console.log(`Data dir: ${DATA_DIR}`);
145
- console.log(`Starting Stagent on http://localhost:${actualPort}`);
146
- const child = spawn(nextBin, ["dev", "--turbopack", "--port", String(actualPort)], {
470
+ console.log(`Mode: ${isPrebuilt ? "production" : "development"}`);
471
+ console.log(`Next entry: ${nextEntrypoint}`);
472
+ console.log(`Starting Stagent on ${sidecarUrl}`);
473
+ const child = spawn(process.execPath, [nextEntrypoint, ...nextArgs], {
147
474
  cwd: effectiveCwd,
148
475
  stdio: "inherit",
149
476
  env: {
@@ -156,7 +483,7 @@ async function main() {
156
483
  setTimeout(async () => {
157
484
  try {
158
485
  const open = (await import("open")).default;
159
- await open(`http://localhost:${actualPort}`);
486
+ await open(sidecarUrl);
160
487
  } catch {
161
488
  }
162
489
  }, 3e3);