@notis_ai/cli 0.2.0-beta.145.1 → 0.2.0-beta.151.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.
@@ -102,11 +102,11 @@ App code never accesses the runtime directly -- it uses SDK hooks (`useTool`, `u
102
102
  17. **Development identities stay separate** -- `.notis/state.json` uses `dev_app_id` for the hidden development-runtime row and `app_id` only for an accessible installed workspace app, scoped under the authenticated environment. Never pass a runtime app whose manifest has `is_dev: true` to `notis apps link`.
103
103
  18. **Automatic mounts are multi-instance and least-authority** -- Prod, Beta, and source-development Desktop instances may mount the same source simultaneously with independent authenticated runtimes. Automatic mounting never grants capabilities: reuse existing grants and leave restricted capabilities denied until approved. Consumer leases expire after crashes so the shared host exits after the last live instance. There are no offline rows or manual start/stop controls.
104
104
  19. **Store submission is user-gated** -- Run `apps publish --confirm-ready` only after the user explicitly confirms the current App Details page and Store listing are ready. Deploy the exact approved local state first. The command must reject missing confirmation, incomplete listing media, a local/deployed version mismatch, private visibility, or an existing pending review.
105
- 20. **Bump `notisAppVersion` for every Store update** -- `package.json` must contain a semver `notisAppVersion`. For an existing Store app, increment it beyond the currently published registry version before deploy and submission; registry CI rejects equal or lower versions.
105
+ 20. **Bump `notisAppVersion` before linked development and every Store update** -- `package.json` must contain a semver `notisAppVersion`. A linked local source substitutes its installed Workspace app only when the local version is strictly greater than the installed manifest's `release_version`; equal, lower, missing, or invalid versions keep serving the online bundle. `apps pull` retrieves the online version, so increment `notisAppVersion` before `apps dev` when continuing development. For an existing Store app, also increment it beyond the currently published registry version before deploy and submission; registry CI rejects equal or lower versions.
106
106
  21. **`CHANGELOG.md` owns release history** -- Keep the complete release history in one root `CHANGELOG.md`, newest entry first. Do not add new `versionNotes` values to `notis.config.ts`. Use `## [Release title] - YYYY-MM-DD`, or `{PR_MERGE_DATE}` for an unpublished entry. App Details reads **What’s New** and **Version History** from the deployed package manifest, while the Store reads them from the latest published snapshot; unpublished workspace edits must never change the Store page. The manifest also exposes `package.json` `notisAppVersion` as the package version shown in App Details.
107
107
  22. **Database rows are private unless explicitly seeded** -- A string declaration such as `databases: ['notes']` publishes schema only and never includes the developer's rows. Use `{ slug: 'templates', seedDocuments: true }` only for small, intentional starter content that every installer should receive. Never enable it for user-created notes, history, leads, or other personal data.
108
108
  23. **Public submissions are complete, reviewable packages** -- The registry PR must contain the full editable source tree, Store assets, exact source-declared database schemas, and only explicitly seeded starter rows. Registry CI validates those boundaries before merge; do not hand-edit `notis-listing.json` or strip source files to make a check pass. Fix the app locally, redeploy, and resubmit.
109
- 24. **New projects land in `~/.notis/apps/<slug>`, and `[dir]` overrides it** -- `apps init` and `apps pull` default to that root, the same well-known home the desktop uses for synced skills (`~/.notis/skills`), so a project nobody deliberately placed is always findable and never depends on where the shell happened to be sitting. Pass `[dir]` whenever the app belongs somewhere specific -- a git repo the user tracks, an existing monorepo's `apps/<name>/`, or a path the user named -- and tell the user which location you used. Never place a project inside the Notis source repo or a Conductor worktree: every later CLI call from that directory resolves the worktree's local dev runtime (`.context/notis-runtime.json`) instead of the user's real profile.
109
+ 24. **New projects default to `~/.notis/apps/<slug>`, and `[dir]` overrides it** -- `apps init` and `apps pull` use this stable, predictable home unless the app belongs in a specific repository, monorepo, or user-chosen location. In those cases, pass `[dir]` and report the resulting path. Do not nest an app inside a directory whose local workspace metadata selects an unrelated Notis runtime or profile: later CLI calls inherit that routing and may target the wrong environment.
110
110
 
111
111
  ## Anti-patterns -- NEVER do these
112
112
 
@@ -130,10 +130,10 @@ These are the most common mistakes agents make. Each one wastes time and produce
130
130
  **Default to the Store scaffold catalog, not a blank project.** Every published Store app is a scaffold: `notis apps scaffolds list` reads the catalog from the public registry, and `notis apps init --from <slug>` downloads that app's source. Most user requests overlap with a published app, and starting from one is faster than a bare app.
131
131
 
132
132
  1. **Find a starting point.** Run `npx --package @notis_ai/cli@latest -- notis apps scaffolds list` (add `--search <term>` to filter) to list the published Store apps. If something close matches, run `npx --package @notis_ai/cli@latest -- notis apps init "My App" --from <slug>` to download that app's source from the registry. Only run plain `notis apps init "My App"` when no published app fits. Either way the project lands in `~/.notis/apps/<slug>`; add a `[dir]` argument when the user wants it somewhere else (a tracked git repo, an existing monorepo), and report the path you used.
133
- 2. **Pull your own apps; fork Store apps with `--from`.** `apps pull` is for apps the user already has installed or deployed: run `npx --package @notis_ai/cli@latest -- notis apps list`, then `npx --package @notis_ai/cli@latest -- notis apps pull <app-id>` (lands in `~/.notis/apps/<app-slug>`; pass a `[dir]` argument to place it elsewhere). To fork a published Store app, use `apps init --from <slug>` instead -- it downloads the source from the registry and does not require installing the app first.
133
+ 2. **Pull your own apps; fork Store apps with `--from`.** `apps pull` is for apps the user already has installed or deployed: run `npx --package @notis_ai/cli@latest -- notis apps list`, preserve any local edits in the target directory, then run `npx --package @notis_ai/cli@latest -- notis apps pull <app-id>` (lands in `~/.notis/apps/<app-slug>`; pass a `[dir]` argument to place it elsewhere). A pull reproduces the installed release, so increment `package.json` `notisAppVersion` above that release before `apps dev`; until then the online bundle remains active. To fork a published Store app, use `apps init --from <slug>` instead -- it downloads the source from the registry and does not require installing the app first.
134
134
  3. **Edit the listing source.** Update `name` (slug), `title`, description, icon, accent, author, categories, tagline, databases, routes, and tools in `notis.config.ts`. Declare a database as a string for schema-only Store packaging; use `{ slug: 'templates', seedDocuments: true }` only when its rows are deliberate starter content for every installer. Keep the complete Store release history in the root `CHANGELOG.md`, newest entry first, using `## [Release title] - YYYY-MM-DD` (or `{PR_MERGE_DATE}` before publication). The first entry powers **What’s New** and the same file powers **Version History**. `icon` is a `phosphor:<name>` value or `metadata/icon.png`; when unset the app shows its **two-letter initials** everywhere (store, sidebar, app details). `accent` optionally pins the avatar color to one of `blue|violet|emerald|amber|rose|sky|fuchsia|teal` (default derived from the app id). Icon/accent flow through deploy onto the app row + listing and can also be set later via the `update_app` tool.
135
135
  4. **Build pages in `app/`.** Reuse scaffold code wherever it fits.
136
- 5. **Iterate live.** Run `npx --package @notis_ai/cli@latest -- notis apps dev [folder]` once. It permanently registers the root; `~/.notis/apps` is already implicit. Running Desktop instances discover the root itself, direct app children, and `apps/*`, then mount each successful build automatically in Workspace. Source edits hot-reload only that app in every instance. Add `--scratch` for isolated empty resources when the work needs fixtures or destructive experiments.
136
+ 5. **Iterate live.** Before developing a linked installed app, increment `package.json` `notisAppVersion` above the installed release. Then run `npx --package @notis_ai/cli@latest -- notis apps dev [folder]` once. It permanently registers the root; `~/.notis/apps` is already implicit. Running Desktop instances discover the root itself, direct app children, and `apps/*`, then mount each successful build automatically in Workspace. A linked build substitutes the online bundle only while its version is strictly greater; equal or stale builds remain served online. Source edits hot-reload only that app in every instance. Add `--scratch` for isolated empty resources when the work needs fixtures or destructive experiments.
137
137
  6. **Capture listing screenshots.** Declare 3–6 screenshots in `notis.config.ts`, each with a stable `path`, descriptive `alt`, and optional `route`/`scenario`/`focus`/`theme`, then run `npx --package @notis_ai/cli@latest -- notis apps screenshot`. Use `focus` to frame a real app root without empty browser canvas; use `theme: 'light'` or `theme: 'dark'` to match both the Portal render and Store backdrop, and pair both modes when that best represents the app. It renders the configured states in a headless harness and writes exact 2000x1250 PNGs under `metadata/`, using the deterministic Store presentation by default (`--raw` is diagnostic only). Apps are icon-led like Raycast — the icon set in `notis.config.ts` represents the app, so there is no cover image, only these screenshots. Never hand-author the PNGs; regenerate them when routes or UI change. A `scenario` names an entry in `metadata/screenshot-fixtures.json`; besides `actions` it may carry its own `tools` and `requests`, shallow-merged per key over the file-level ones for that capture, which is how the same route is shown both populated and in its first-run empty state.
138
138
  7. **Verify locally.** Run `npm install`, then `npx --package @notis_ai/cli@latest -- notis apps build` and `npx --package @notis_ai/cli@latest -- notis apps verify`. Surface the verify report and fix failures. Incomplete listing media is only a `Store readiness:` warning there; run `notis apps verify --listing` before publish to make it a failure.
139
139
  8. **Local-development-first handoff — STOP HERE.** Hand off after the user can see and test the app in its DEV-badged Workspace row. Building a new app to this point, without deploying, is a **complete and expected** result. Do NOT proceed to `apps create` / `apps deploy` yet. **Before handing off, complete all three acceptance checks:**
@@ -191,6 +191,7 @@ npx --package @notis_ai/cli@latest -- notis apps list
191
191
  npx --package @notis_ai/cli@latest -- notis apps pull <installed-app-id>
192
192
  cd ~/.notis/apps/my-app
193
193
  npm install
194
+ # Increment package.json notisAppVersion above the pulled online release.
194
195
  npx --package @notis_ai/cli@latest -- notis apps dev
195
196
  npx --package @notis_ai/cli@latest -- notis apps build
196
197
  npx --package @notis_ai/cli@latest -- notis apps verify
@@ -234,6 +235,8 @@ routes: [
234
235
 
235
236
  Use the same page template for the root Notes route and collection/sub-collection detail states. The portal sidebar injects live collection items under the static route row when `collection.sidebar.mode === 'tree'`.
236
237
 
238
+ For arbitrary app-owned resources that are not Notis collection rows, set `resourceDeepLinks: true` on the route. Read the decoded `?resource=` identifier from `useNotis().resourceId`, and link between routes with `toRoute('/inbox', { resourceId })`. Keep collection links on `?item=`. Publish external preview/source links as the resource `url`; the host separately supplies the exact Notis review link as `active_resource.view_url` for opted-in routes. Handle missing or deleted identifiers with a safe view-level fallback.
239
+
237
240
  ### Step 2: Build pages
238
241
 
239
242
  Standard React pages in `app/`. Use generic SDK tool hooks for data and build on top of the scaffolded shadcn components and portal shell classes (`notis-app-shell`, `notis-app-surface`):
@@ -444,10 +447,10 @@ already installs `ShortcutProvider`; app code should not add a second provider.
444
447
 
445
448
  | API | Signature | Description |
446
449
  |-----|-----------|-------------|
447
- | `useNotis()` | `() => { app, route, context, ready }` | App metadata, current route, generic portal context, ready state |
450
+ | `useNotis()` | `() => { app, route, databases, collectionItem, resourceId, ready }` | App metadata, current route, selected collection item, decoded exact-resource id, ready state |
448
451
  | `useTool<TArgs, TResult>(name)` | `(name: string) => { call, loading, error }` | Call a declared tool with app-defined argument/result types. Identical idempotent reads may use `call(args, { dedupe: true })`; never dedupe writes |
449
452
  | `useTools()` | `() => { tools, loading }` | List available tools |
450
- | `useNotisNavigation()` | `() => { toRoute, toDocument, toApp }` | Navigate between routes, documents, or the app root |
453
+ | `useNotisNavigation()` | `() => { toRoute, toDocument, toApp }` | Navigate between routes (including `toRoute(path, { resourceId })`), documents, or the app root |
451
454
  | `useTopBarSearch(opts)` | `({ value, onChange, placeholder?, onSubmit? }) => { setLoading }` | Bind the current view to the Portal-owned top-bar search input |
452
455
  | `useBackend()` | `() => { request }` | Raw backend request proxy with JWT auth |
453
456
  | `useDatabaseSubscription(slug, opts?)` | `(slug: string, opts?) => { rows, documents, loading, error, refetch, live }` | Query a database and refetch it when its rows change. `live` is false on hosts without a change feed (dev harness, vite preview) -- keep a manual refresh for those |
@@ -509,13 +512,13 @@ const result = await queryTasks.call({ database_id: 'tasks-db-id', query: { page
509
512
  npx --package @notis_ai/cli@latest -- notis apps dev
510
513
  ```
511
514
 
512
- Runs the real desktop-local development workflow. The CLI should discover all apps in the target workspace, serve their bundles from loopback, and surface them in the Electron Portal's Workspace group each substituted for its installed entry, or appended while unpublished through the local desktop session registry.
515
+ Runs the real desktop-local development workflow. The CLI should discover all apps in the target workspace and serve their bundles from loopback. Unpublished apps appear in the Electron Portal's Workspace group; linked apps substitute their installed entry only when the local `notisAppVersion` is strictly greater than the installed `release_version`.
513
516
 
514
517
  ## Testing
515
518
 
516
519
  1. **Build validation**: `npx --package @notis_ai/cli@latest -- notis apps build` must succeed without errors. Vite surfaces TypeScript and bundling errors during this step.
517
520
  2. **Headless render verification** (recommended after every build): run `npx --package @notis_ai/cli@latest -- notis apps verify`. It builds unless `--skip-build` is passed, spins up a loopback harness, drives `agent-browser` against every route, and reports per-route pass/fail with captured render errors and runtime calls.
518
- 3. **Local development acceptance**: Run `notis apps dev [folder]` once to register the root, then verify each signed-in Desktop instance independently: one DEV-badged Workspace row, the default route rendering real app content, and live edits appearing without restarting the CLI or Desktop. Use `notis apps roots list` as the persistence proof. Loopback bundle health alone does not prove that an authenticated instance mounted or rendered the app.
521
+ 3. **Local development acceptance**: Run `notis apps dev [folder]` once to register the root, then verify each signed-in Desktop instance independently. For an unpublished app, expect one DEV-badged Workspace row. For a linked app, first confirm local `notisAppVersion` is strictly greater than installed `release_version`, then expect one substituted DEV-badged row; equal or lower must keep the online row and bundle. Verify the default route renders and live edits appear without restarting the CLI or Desktop. Use `notis apps roots list` as the persistence proof. Loopback bundle health alone does not prove that an authenticated instance mounted or rendered the app.
519
522
  4. **Post-deploy**: Verify the deployed bundle via `/portal_views/get` -> `runtime_descriptor.bundle.js_url`, then verify the app renders in the portal. The portal renders app bundles directly as React components, so the fastest verification is navigating to the app page in the portal.
520
523
 
521
524
  ### Headless harness verification
@@ -544,6 +547,6 @@ Run `npx --package @notis_ai/cli@latest -- notis apps verify` after `npx --packa
544
547
  authenticated API available. Do not bypass DEV-app promotion or installed-app
545
548
  identity with a direct database/storage write.
546
549
  - **App shows old code after deploy**: Bundle cache is stale. Hard refresh (Cmd+Shift+R) or clear site data in DevTools.
547
- - **App is missing from the sidebar**: Run `apps roots list`, confirm the app is at the root, one direct child, or `apps/*`, and confirm its first build succeeds. Restarting Desktop reattaches the same persistent roots; no terminal process or manual sidebar action is required.
550
+ - **App is missing from the sidebar**: Run `apps roots list`, confirm the app is at the root, one direct child, or `apps/*`, and confirm its first build succeeds. For a linked app, compare `package.json` `notisAppVersion` with the installed manifest's `release_version`: equal, lower, missing, or invalid intentionally keeps the online app without a DEV badge. If source is stale, preserve any local edits, run `apps pull <app-id> <dir> --force` to refresh it, then increment `notisAppVersion` before continuing development. Restarting Desktop reattaches the same persistent roots; no terminal process or manual sidebar action is required.
548
551
  - **`LOCAL_NOTIS_DATABASE_QUERY` returns empty documents**: Check that the database ID passed to the tool matches the intended database. Use `npx --package @notis_ai/cli@latest -- notis tools exec LOCAL_NOTIS_DATABASE_LIST_DATABASES --arguments '{}'` to verify the ID; use the database slug only as a fallback.
549
552
  - **Properties are `undefined`**: Keep app-local result types for `useTool<TArgs, TResult>` and guard optional nested properties when reading live data.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@notis_ai/cli",
3
- "version": "0.2.0-beta.145.1",
3
+ "version": "0.2.0-beta.151.1",
4
4
  "description": "Agent-first Notis CLI for apps and generic tool execution",
5
5
  "type": "module",
6
6
  "bin": {
@@ -118,7 +118,7 @@ Examples:
118
118
 
119
119
  Register a development root and connect its apps to the shared local development host.
120
120
 
121
- When to use: Run this once for any folder that should be watched permanently. The folder itself, direct child apps, and apps/* are discovered automatically by every signed-in Notis Desktop instance.
121
+ When to use: Run this once for any folder that should be watched permanently. The folder itself, direct child apps, and apps/* are discovered automatically by every signed-in Notis Desktop instance. A linked app substitutes its online bundle only when local notisAppVersion is strictly greater than the installed release.
122
122
 
123
123
  Options:
124
124
  - `--port <number>` — Local bundle server port (default: 5173).
@@ -218,7 +218,7 @@ Examples:
218
218
 
219
219
  Download a Notis app source snapshot into a local project folder.
220
220
 
221
- When to use: Edit an installed app locally. Pulls the persisted source, links the directory to the app/version, then continue with npm install, notis apps dev, notis apps build, and notis apps deploy.
221
+ When to use: Edit an installed app locally. Preserve any local edits, pull and link the latest persisted source, then increment package.json notisAppVersion above that release before notis apps dev; continue with build and deploy.
222
222
 
223
223
  Options:
224
224
  - `--force` — Overwrite a non-empty target directory.
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import { randomUUID } from 'node:crypto';
11
- import { mkdirSync, mkdtempSync, readdirSync, rmSync } from 'node:fs';
11
+ import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs';
12
12
  import { tmpdir } from 'node:os';
13
13
  import { basename, isAbsolute, join, relative, resolve, sep } from 'node:path';
14
14
 
@@ -36,6 +36,7 @@ import {
36
36
  normalizeAppCapabilities,
37
37
  normalizeAppToolBindings,
38
38
  normalizeAppSkillManifestPath,
39
+ appRowFieldsFromManifest,
39
40
  directDeploy,
40
41
  pullAppSource,
41
42
  } from '../runtime/app-platform.js';
@@ -45,6 +46,7 @@ import {
45
46
  scaffoldRegistryLabel,
46
47
  } from '../runtime/app-registry-scaffolds.js';
47
48
  import { startAppDevServer } from '../runtime/app-dev-server.js';
49
+ import { captureDesktopHostOwnership } from '../runtime/app-dev-process-identity.js';
48
50
  import {
49
51
  discoverRegisteredAppProjects,
50
52
  readAppDevRoots,
@@ -87,6 +89,8 @@ import {
87
89
  toolConflictToError,
88
90
  } from './helpers.js';
89
91
 
92
+ export { appRowFieldsFromManifest } from '../runtime/app-platform.js';
93
+
90
94
  const DEFAULT_DEV_PORT = 5173;
91
95
  const DEV_HEARTBEAT_INTERVAL_MS = 10_000;
92
96
  const DEV_CONSUMER_HEARTBEAT_INTERVAL_MS = 3_000;
@@ -98,6 +102,72 @@ function projectIsWithinRoot(projectDir, rootDir) {
98
102
  return nested === '' || (nested !== '..' && !nested.startsWith(`..${sep}`) && !isAbsolute(nested));
99
103
  }
100
104
 
105
+ function parseNotisAppVersion(value) {
106
+ const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.exec(
107
+ String(value || '').trim(),
108
+ );
109
+ if (!match) return null;
110
+ const prerelease = match[4] ? match[4].split('.') : [];
111
+ if (prerelease.some((identifier) => /^\d+$/.test(identifier) && identifier.length > 1 && identifier.startsWith('0'))) {
112
+ return null;
113
+ }
114
+ return {
115
+ major: match[1],
116
+ minor: match[2],
117
+ patch: match[3],
118
+ prerelease,
119
+ };
120
+ }
121
+
122
+ function compareNumericSemverIdentifiers(left, right) {
123
+ if (left.length !== right.length) return left.length > right.length ? 1 : -1;
124
+ return left === right ? 0 : left > right ? 1 : -1;
125
+ }
126
+
127
+ function compareNotisPrerelease(left, right) {
128
+ if (left.length === 0 || right.length === 0) {
129
+ return left.length === right.length ? 0 : left.length === 0 ? 1 : -1;
130
+ }
131
+ const length = Math.max(left.length, right.length);
132
+ for (let index = 0; index < length; index += 1) {
133
+ const leftIdentifier = left[index];
134
+ const rightIdentifier = right[index];
135
+ if (leftIdentifier === undefined || rightIdentifier === undefined) {
136
+ return leftIdentifier === rightIdentifier ? 0 : leftIdentifier === undefined ? -1 : 1;
137
+ }
138
+ if (leftIdentifier === rightIdentifier) continue;
139
+ const leftNumeric = /^\d+$/.test(leftIdentifier);
140
+ const rightNumeric = /^\d+$/.test(rightIdentifier);
141
+ if (leftNumeric && rightNumeric) {
142
+ return compareNumericSemverIdentifiers(leftIdentifier, rightIdentifier);
143
+ }
144
+ if (leftNumeric !== rightNumeric) return leftNumeric ? -1 : 1;
145
+ return leftIdentifier > rightIdentifier ? 1 : -1;
146
+ }
147
+ return 0;
148
+ }
149
+
150
+ export function compareNotisAppVersions(leftValue, rightValue) {
151
+ const left = parseNotisAppVersion(leftValue);
152
+ const right = parseNotisAppVersion(rightValue);
153
+ if (!left || !right) return null;
154
+ for (const key of ['major', 'minor', 'patch']) {
155
+ const comparison = compareNumericSemverIdentifiers(left[key], right[key]);
156
+ if (comparison !== 0) return comparison;
157
+ }
158
+ return compareNotisPrerelease(left.prerelease, right.prerelease);
159
+ }
160
+
161
+ function readLocalNotisAppVersion(projectDir) {
162
+ try {
163
+ const packageJson = JSON.parse(readFileSync(join(projectDir, 'package.json'), 'utf8'));
164
+ const version = String(packageJson.notisAppVersion || '').trim();
165
+ return parseNotisAppVersion(version) ? version : null;
166
+ } catch {
167
+ return null;
168
+ }
169
+ }
170
+
101
171
  /**
102
172
  * A CLI launch may add one root, but the shared host always serves the complete
103
173
  * machine registry. Desktop sets skipRootRegistration because it is reconciling
@@ -410,13 +480,6 @@ export function screenshotIndexByRouteSlug(manifest) {
410
480
  return new Map(routes.map((route, index) => [route.slug, index + 1]));
411
481
  }
412
482
 
413
- export function appRowFieldsFromManifest(manifest) {
414
- const app = manifest?.app && typeof manifest.app === 'object' ? manifest.app : {};
415
- return {
416
- accent: app.accent ?? null,
417
- };
418
- }
419
-
420
483
  export function screenshotExitCode(failedCount) {
421
484
  return failedCount === 0 ? EXIT_CODES.ok : EXIT_CODES.unexpected;
422
485
  }
@@ -570,7 +633,7 @@ function renderVerifyReport({ summary, results, noBrowser }) {
570
633
  return lines.join('\n');
571
634
  }
572
635
 
573
- function buildManifestForDev(appConfig) {
636
+ function buildManifestForDev(appConfig, projectDir) {
574
637
  const routes = Array.isArray(appConfig.routes) ? appConfig.routes : [];
575
638
  return {
576
639
  version: 1,
@@ -579,6 +642,7 @@ function buildManifestForDev(appConfig) {
579
642
  name: appConfig.name,
580
643
  description: appConfig.description || null,
581
644
  icon: appConfig.icon || null,
645
+ release_version: readLocalNotisAppVersion(projectDir),
582
646
  },
583
647
  routes: routes.map((route) => ({
584
648
  path: route.path,
@@ -587,6 +651,7 @@ function buildManifestForDev(appConfig) {
587
651
  icon: route.icon || null,
588
652
  parentSlug: route.parentSlug || null,
589
653
  default: route.default || false,
654
+ resourceDeepLinks: route.resourceDeepLinks === true,
590
655
  export_name: route.exportName || route.export_name,
591
656
  collection: route.collection || null,
592
657
  })),
@@ -726,7 +791,7 @@ export async function ensureDevInstall({
726
791
  throw usageError(`notis.config.ts devSlug or name in ${projectDir} must slugify to a non-empty value.`);
727
792
  }
728
793
 
729
- const manifest = buildManifestForDev(appConfig);
794
+ const manifest = buildManifestForDev(appConfig, projectDir);
730
795
  const skills = resolveConfiguredAppSkills(appConfig, projectDir);
731
796
  const profileKey = linkedStateProfileKey(ctx.runtime);
732
797
  let linkedState = readLinkedState(projectDir, profileKey);
@@ -801,6 +866,10 @@ export async function ensureDevInstall({
801
866
  ].includes(key)),
802
867
  )
803
868
  : linkedState;
869
+ const localReleaseVersion = manifest.app.release_version || null;
870
+ const installedReleaseVersion = linkedApp?.manifest?.app?.release_version || '0.0.0';
871
+ const mountEligible = !linkedApp
872
+ || compareNotisAppVersions(localReleaseVersion, installedReleaseVersion) === 1;
804
873
  const ensureArguments = buildEnsureDevInstallArguments({
805
874
  appConfig,
806
875
  manifest,
@@ -866,6 +935,9 @@ export async function ensureDevInstall({
866
935
  linkedAppId: runtimeLinkedState?.app_id || null,
867
936
  targetAppId: runtimeLinkedState?.app_id || null,
868
937
  targetAppSlug: linkedApp?.slug || null,
938
+ localReleaseVersion,
939
+ installedReleaseVersion: linkedApp ? installedReleaseVersion : null,
940
+ mountEligible,
869
941
  databaseMaterialization: ensureResult.payload.database_materialization || { created: [], unresolved: [] },
870
942
  liveData: ensureResult.payload.live_data || null,
871
943
  };
@@ -892,6 +964,16 @@ function liveDataWarnings(apps) {
892
964
  .map((app) => `${app.name}: ${app.liveData.warning}`);
893
965
  }
894
966
 
967
+ function versionPrecedenceWarnings(apps) {
968
+ return apps
969
+ .filter((app) => app.targetAppId && app.mountEligible === false)
970
+ .map((app) => {
971
+ const localVersion = app.localReleaseVersion || 'missing or invalid';
972
+ const pullCommand = `npx --package @notis_ai/cli@latest -- notis apps pull ${app.targetAppId} ${JSON.stringify(app.projectDir)} --force`;
973
+ return `${app.name}: local version ${localVersion} is not strictly newer than installed version ${app.installedReleaseVersion}; Workspace keeps serving the online bundle. Preserve any local edits, pull latest with \`${pullCommand}\`, then bump package.json notisAppVersion before development.`;
974
+ });
975
+ }
976
+
895
977
  async function getAccessibleApp(runtime, appId, runTool = runToolCommand) {
896
978
  const result = await runTool({
897
979
  runtime,
@@ -1305,6 +1387,7 @@ async function appsDevHandler(ctx) {
1305
1387
  isBundleReady: () => true,
1306
1388
  updateApp: () => {},
1307
1389
  waitForBundle: async () => {},
1390
+ getWatcherOwnership: () => null,
1308
1391
  }
1309
1392
  : await startAppDevServer({
1310
1393
  apps: canonicalCandidates.map((app) => ({
@@ -1320,6 +1403,10 @@ async function appsDevHandler(ctx) {
1320
1403
  port,
1321
1404
  sessionsFilePath,
1322
1405
  });
1406
+ const desktopHostOwnership = captureDesktopHostOwnership({
1407
+ desktopOwnerId: process.env.NOTIS_APPS_DEV_DESKTOP_OWNER_ID,
1408
+ desktopOwnerScope: process.env.NOTIS_APPS_DEV_DESKTOP_OWNER_SCOPE,
1409
+ });
1323
1410
 
1324
1411
  let heartbeatTimer = setInterval(() => {
1325
1412
  try {
@@ -1379,6 +1466,8 @@ async function appsDevHandler(ctx) {
1379
1466
  sessionId,
1380
1467
  hostPid: process.pid,
1381
1468
  sourceHost: !sharedBundleBaseUrls,
1469
+ ...(desktopHostOwnership || {}),
1470
+ ...(devServer.getWatcherOwnership(app.devSlug) || {}),
1382
1471
  bundleReady: devServer.isBundleReady(app.devSlug),
1383
1472
  ...(!sharedBundleBaseUrls ? {
1384
1473
  discoveredProjects: discoveredAppDirs,
@@ -1456,6 +1545,7 @@ async function appsDevHandler(ctx) {
1456
1545
  ...registrationWarnings,
1457
1546
  ...databaseMaterializationWarnings(apps),
1458
1547
  ...liveDataWarnings(apps),
1548
+ ...versionPrecedenceWarnings(apps),
1459
1549
  ];
1460
1550
 
1461
1551
  let consumerTimer = null;
@@ -1481,6 +1571,9 @@ async function appsDevHandler(ctx) {
1481
1571
  linked_app_id: app.linkedAppId,
1482
1572
  database_materialization: app.databaseMaterialization,
1483
1573
  live_data: app.liveData,
1574
+ local_release_version: app.localReleaseVersion,
1575
+ installed_release_version: app.installedReleaseVersion,
1576
+ mount_eligible: app.mountEligible,
1484
1577
  })),
1485
1578
  },
1486
1579
  warnings,
@@ -1492,11 +1585,15 @@ async function appsDevHandler(ctx) {
1492
1585
  ? [`Databases: ${apps.filter((app) => app.liveData?.enabled).length}/${apps.length} app(s) reading the installed app's live rows`]
1493
1586
  : []),
1494
1587
  '',
1495
- ...apps.map((app) => ` ${app.name.padEnd(24)} ${app.bundleBaseUrl} -> ${app.appHref}`),
1588
+ ...apps.map((app) => (
1589
+ app.mountEligible
1590
+ ? ` ${app.name.padEnd(24)} ${app.bundleBaseUrl} -> ${app.appHref}`
1591
+ : ` ${app.name.padEnd(24)} online v${app.installedReleaseVersion} (local ${app.localReleaseVersion || 'version missing'})`
1592
+ )),
1496
1593
  '',
1497
1594
  sharedBundleBaseUrls
1498
- ? `Mounted ${apps.length} shared source app${apps.length === 1 ? '' : 's'} for this authenticated environment.`
1499
- : `Serving one shared loopback host for ${apps.length} app${apps.length === 1 ? '' : 's'}.`,
1595
+ ? `Attached to the shared source host: ${apps.filter((app) => app.mountEligible).length}/${apps.length} app${apps.length === 1 ? '' : 's'} eligible to substitute.`
1596
+ : `Serving one shared loopback host for ${apps.length} app${apps.length === 1 ? '' : 's'}; ${apps.filter((app) => app.mountEligible).length} eligible to substitute.`,
1500
1597
  '',
1501
1598
  'Press Ctrl-C to stop.',
1502
1599
  ].join('\n'),
@@ -1527,12 +1624,15 @@ async function appsDevHandler(ctx) {
1527
1624
  }
1528
1625
  }
1529
1626
  try {
1530
- removeAppDevSession(sessionId, sessionsFilePath);
1627
+ await devServer.close();
1531
1628
  } catch {
1532
1629
  // ignore cleanup failures during shutdown
1533
1630
  }
1631
+ // Keep ownership records until every watcher group has stopped. If the
1632
+ // Desktop must force this host down, the next launch can still recover a
1633
+ // verified orphan instead of losing its only ownership proof.
1534
1634
  try {
1535
- await devServer.close();
1635
+ removeAppDevSession(sessionId, sessionsFilePath);
1536
1636
  } catch {
1537
1637
  // ignore cleanup failures during shutdown
1538
1638
  }
@@ -2148,7 +2248,7 @@ async function appsPullHandler(ctx) {
2148
2248
  project_dir: pulled.projectDir,
2149
2249
  version: pulled.version,
2150
2250
  },
2151
- humanSummary: `Pulled ${versionLabel} to ${pulled.projectDir}. Run \`cd ${pulled.projectDir} && npm install && notis apps dev\` to start editing.`,
2251
+ humanSummary: `Pulled ${versionLabel} to ${pulled.projectDir}. Increment package.json notisAppVersion above the pulled release, then run \`cd ${pulled.projectDir} && npm install && notis apps dev\` to substitute the online bundle.`,
2152
2252
  });
2153
2253
  }
2154
2254
 
@@ -2599,7 +2699,7 @@ export const appsCommandSpecs = [
2599
2699
  command_path: ['apps', 'dev'],
2600
2700
  summary: 'Register a development root and connect its apps to the shared local development host.',
2601
2701
  when_to_use:
2602
- 'Run this once for any folder that should be watched permanently. The folder itself, direct child apps, and apps/* are discovered automatically by every signed-in Notis Desktop instance.',
2702
+ 'Run this once for any folder that should be watched permanently. The folder itself, direct child apps, and apps/* are discovered automatically by every signed-in Notis Desktop instance. A linked app substitutes its online bundle only when local notisAppVersion is strictly greater than the installed release.',
2603
2703
  args_schema: {
2604
2704
  arguments: [
2605
2705
  { token: '[dir]', key: 'dir', description: 'Project directory or monorepo root (default: current dir).' },
@@ -2775,7 +2875,7 @@ export const appsCommandSpecs = [
2775
2875
  command_path: ['apps', 'pull'],
2776
2876
  summary: 'Download a Notis app source snapshot into a local project folder.',
2777
2877
  when_to_use:
2778
- 'Edit an installed app locally. Pulls the persisted source, links the directory to the app/version, then continue with npm install, notis apps dev, notis apps build, and notis apps deploy.',
2878
+ 'Edit an installed app locally. Preserve any local edits, pull and link the latest persisted source, then increment package.json notisAppVersion above that release before notis apps dev; continue with build and deploy.',
2779
2879
  args_schema: {
2780
2880
  arguments: [
2781
2881
  { token: '<app-id>', description: 'Remote app ID to pull.' },
@@ -0,0 +1,111 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { readlinkSync, realpathSync } from 'node:fs';
3
+
4
+ export const NOTIS_APP_BUILD_COMMAND_FINGERPRINT = 'npm:run-build:watch:v1';
5
+ export const NOTIS_APPS_DEV_HOST_COMMAND_FINGERPRINT = 'notis:apps-dev:v1';
6
+
7
+ function normalizePath(value) {
8
+ if (typeof value !== 'string' || !value.trim()) return null;
9
+ try {
10
+ return realpathSync(value.trim());
11
+ } catch {
12
+ return null;
13
+ }
14
+ }
15
+
16
+ export function isExpectedNotisBuildCommand(command) {
17
+ if (typeof command !== 'string') return false;
18
+ return /(?:^|[/\s])npm(?:\s|$)/.test(command)
19
+ && /\brun\s+build\b/.test(command)
20
+ && /(?:^|\s)--watch(?:\s|$)/.test(command);
21
+ }
22
+
23
+ export function isExpectedNotisAppsDevHostCommand(command) {
24
+ return typeof command === 'string'
25
+ && /(?:notis(?:\.js)?|@notis_ai[/\\]cli)/.test(command)
26
+ && /\bapps\s+dev\b/.test(command);
27
+ }
28
+
29
+ function readDarwinProcessCwd(pid, execute) {
30
+ const output = execute('lsof', ['-a', '-p', String(pid), '-d', 'cwd', '-Fn'], {
31
+ encoding: 'utf8',
32
+ stdio: ['ignore', 'pipe', 'ignore'],
33
+ });
34
+ const line = output.split('\n').find((entry) => entry.startsWith('n'));
35
+ return line ? line.slice(1) : null;
36
+ }
37
+
38
+ export function inspectAppDevWatcherProcess(pid, {
39
+ platform = process.platform,
40
+ execute = execFileSync,
41
+ readLink = readlinkSync,
42
+ } = {}) {
43
+ if (!Number.isSafeInteger(pid) || pid <= 0 || platform === 'win32') return null;
44
+ try {
45
+ const ps = (field) => execute('ps', ['-o', `${field}=`, '-p', String(pid)], {
46
+ encoding: 'utf8',
47
+ stdio: ['ignore', 'pipe', 'ignore'],
48
+ }).trim();
49
+ const processGroupPid = Number.parseInt(ps('pgid'), 10);
50
+ const startIdentity = ps('lstart').replace(/\s+/g, ' ').trim();
51
+ const command = ps('command');
52
+ const cwd = platform === 'linux'
53
+ ? readLink(`/proc/${pid}/cwd`)
54
+ : readDarwinProcessCwd(pid, execute);
55
+ const projectDir = normalizePath(cwd);
56
+ if (!Number.isSafeInteger(processGroupPid) || processGroupPid <= 0) return null;
57
+ if (!startIdentity || !command || !projectDir) return null;
58
+ return { pid, processGroupPid, startIdentity, command, projectDir };
59
+ } catch {
60
+ return null;
61
+ }
62
+ }
63
+
64
+ export function captureDesktopWatcherOwnership({
65
+ pid,
66
+ projectDir,
67
+ desktopOwnerId,
68
+ desktopOwnerScope,
69
+ inspect = inspectAppDevWatcherProcess,
70
+ } = {}) {
71
+ const owner = typeof desktopOwnerId === 'string' ? desktopOwnerId.trim() : '';
72
+ const ownerScope = typeof desktopOwnerScope === 'string' ? desktopOwnerScope.trim() : '';
73
+ const expectedProjectDir = normalizePath(projectDir);
74
+ if (!owner || !ownerScope || !expectedProjectDir) return null;
75
+ const identity = inspect(pid);
76
+ if (
77
+ !identity
78
+ || identity.processGroupPid !== pid
79
+ || identity.projectDir !== expectedProjectDir
80
+ || !isExpectedNotisBuildCommand(identity.command)
81
+ ) {
82
+ return null;
83
+ }
84
+ return {
85
+ desktopOwnerId: owner,
86
+ desktopOwnerScope: ownerScope,
87
+ watcherProcessGroupPid: identity.processGroupPid,
88
+ watcherStartIdentity: identity.startIdentity,
89
+ watcherProjectDir: identity.projectDir,
90
+ watcherCommandFingerprint: NOTIS_APP_BUILD_COMMAND_FINGERPRINT,
91
+ };
92
+ }
93
+
94
+ export function captureDesktopHostOwnership({
95
+ pid = process.pid,
96
+ desktopOwnerId,
97
+ desktopOwnerScope,
98
+ inspect = inspectAppDevWatcherProcess,
99
+ } = {}) {
100
+ const owner = typeof desktopOwnerId === 'string' ? desktopOwnerId.trim() : '';
101
+ const ownerScope = typeof desktopOwnerScope === 'string' ? desktopOwnerScope.trim() : '';
102
+ if (!owner || !ownerScope) return null;
103
+ const identity = inspect(pid);
104
+ if (!identity || !isExpectedNotisAppsDevHostCommand(identity.command)) return null;
105
+ return {
106
+ desktopOwnerId: owner,
107
+ desktopOwnerScope: ownerScope,
108
+ desktopHostStartIdentity: identity.startIdentity,
109
+ desktopHostCommandFingerprint: NOTIS_APPS_DEV_HOST_COMMAND_FINGERPRINT,
110
+ };
111
+ }