From 1eccb79ee72df2dc8e47dbf1cfe3bc84a2ecce88 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Fri, 2 Oct 2026 12:26:12 -0700 Subject: [PATCH 1/3] Desktop catalog: ship only the Makepad apps OctoSense keeps The desktop's app catalog was generated from Makepad's whole WM registry, so it shipped Mixer, Score, VJ, Fab, Fabric, Video and Studio beside the apps OctoSense supports. desktop/config/apps.overlay.json now names the upstream apps to keep (`pick`): Browser, Files, Terminal, Task, Sheets, Clock, Weather, Finance, Notes, Reminders, Calculator and Route. Image, PDF and AI stay appended as before. upstream.py's merge_catalog keeps only the picked rows, pick_problems names a pick upstream no longer curates, and the catalog command writes the shipped config/apps.json and the full config/apps.makepad.json (still what --apps reads). The phone library's groups follow the kept apps. Co-Authored-By: Claude Opus 5.5 --- crates/shell/src/mobile_tiles.rs | 9 ++-- desktop/README.md | 4 +- desktop/README.zh-CN.md | 4 +- desktop/config/apps.json | 56 ------------------------- desktop/config/apps.makepad.json | 8 ---- desktop/config/apps.overlay.json | 15 +++++++ desktop/docs/add-all-makepad-apps.md | 2 +- desktop/scripts/test_upstream.py | 45 +++++++++++++++++--- desktop/scripts/upstream.py | 62 ++++++++++++++++++++++------ 9 files changed, 115 insertions(+), 90 deletions(-) diff --git a/crates/shell/src/mobile_tiles.rs b/crates/shell/src/mobile_tiles.rs index 7b4a39f3..73116195 100644 --- a/crates/shell/src/mobile_tiles.rs +++ b/crates/shell/src/mobile_tiles.rs @@ -204,11 +204,10 @@ pub fn home_layout_for_apps(screen: Rect, top: f64, dock: Rect, apps: &[&str]) - /// iOS App Library: every launchable app in a category card. Categories are /// fixed by app id (an app the table does not know lands in "Other"), so /// every launch target is reachable from exactly one card. -pub const LIBRARY_GROUPS: [(&str, &[&str]); 5] = [ - ("Utilities", &["clock", "weather", "appcard", "news", "terminal", "files", "task"]), - ("Creativity", &["photos", "mixer", "score", "vj", "fab", "fabric"]), - ("Productivity", &["sheets", "browser", "route", "maps", "studio"]), - ("Media", &["video", "image", "pdf"]), +pub const LIBRARY_GROUPS: [(&str, &[&str]); 4] = [ + ("Utilities", &["clock", "weather", "calculator", "appcard", "news", "terminal", "files", "task"]), + ("Productivity", &["notes", "reminders", "sheets", "browser", "route", "maps", "finance", "aichat"]), + ("Media", &["photos", "image", "pdf"]), ("Other", &[]), ]; diff --git a/desktop/README.md b/desktop/README.md index 93d2b63b..d91764f6 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -29,7 +29,7 @@ The desktop shell of [OctoSense](https://github.com/OctoSense-org), the agent sh | Path | What it is | | --- | --- | | `src/main.rs` | The entry point (package `octosense`): `octosense_main!()` over the shell's `App`. The shell (tiling, launcher, dock, bar, hosting, the app registry, `shell/`, `octosense/`) is [`../crates/shell/src`](../crates/shell/src). | -| `config/apps.json` | The default developer-program catalog. `apps.makepad.json` is an identical copy for `--apps`; `apps.overlay.json` holds the adaptations applied when regenerating them. | +| `config/apps.json` | The default developer-program catalog: the Makepad apps OctoSense picked (`pick` in `apps.overlay.json`), each also a native app in [`../native-apps.json`](../native-apps.json). `apps.makepad.json`, for `--apps`, has every app upstream's registry curates; `apps.overlay.json` holds the adaptations applied when regenerating both. | | `system-apps.json` | Which system apps this build packs, and from where (`../apps`). | | `scripts/` | `package.py` (the release packages, [Release builds](#release-builds)), `upstream.py` (WM provenance and catalog regeneration), `smoke.py` (native smoke test), their Python tests, `system_apps_remote.sh`, `ai_providers_remote.sh` and `glance_remote.sh` (hidden `--remote` end-to-end runs of the system apps and of AI providers), and `provision-appcard-llm.sh` (Android). | | `packaging/` | The release packages' cargo-packager config (`release.json`), app icon (`icons/`, `make_icons.py`) and macOS `Info.plist` additions and entitlements. | @@ -276,6 +276,8 @@ python3 scripts/upstream.py catalog # report drift python3 scripts/upstream.py catalog --apply # rewrite config/apps.json and apps.makepad.json ``` +Only the upstream apps named in the overlay's `pick` reach `config/apps.json`: an app upstream curates later stays out until it is picked, and a pick upstream no longer curates is reported. `--apps config/apps.makepad.json` starts the desktop with all of them. + ### The AppCard assistant AppCard is **not shipped for now**: it interfered with the other apps, so no build links it unless asked. Default, `mobile-apps`, Android and iOS builds leave its UI out (the octos kernel service stays), and it has no tile, group or launcher entry. `--features app-appcard` brings it back on any target (for a phone, pass the feature to `cargo makepad`). diff --git a/desktop/README.zh-CN.md b/desktop/README.zh-CN.md index 3819a8a1..6d8ca42d 100644 --- a/desktop/README.zh-CN.md +++ b/desktop/README.zh-CN.md @@ -29,7 +29,7 @@ | 路径 | 内容 | | --- | --- | | `src/main.rs` | 入口(包名 `octosense`):在 Shell 的 `App` 上调用 `octosense_main!()`。Shell 本身(平铺、launcher、dock、顶栏、托管、应用注册表、`shell/`、`octosense/`)在 [`../crates/shell/src`](../crates/shell/src)。 | -| `config/apps.json` | 默认的开发者程序目录。`apps.makepad.json` 是供 `--apps` 使用的相同副本;`apps.overlay.json` 保存重新生成时应用的调整。 | +| `config/apps.json` | 默认的开发者程序目录:OctoSense 挑选的 Makepad 应用(`apps.overlay.json` 中的 `pick`),每个也是 [`../native-apps.json`](../native-apps.json) 中的原生应用。`apps.makepad.json` 供 `--apps` 使用,包含上游注册表策展的全部应用;`apps.overlay.json` 保存重新生成两者时应用的调整。 | | `system-apps.json` | 本构建打包哪些系统应用,以及从哪里打包(`../apps`)。 | | `scripts/` | `package.py`(发布包,见[发布构建](#发布构建))、`upstream.py`(WM 来源记录与目录重新生成)、`smoke.py`(原生冒烟测试)及它们的 Python 测试,`system_apps_remote.sh`、`ai_providers_remote.sh` 和 `glance_remote.sh`(以隐藏窗口 `--remote` 端到端运行系统应用、AI 提供商与一览屏),以及 `provision-appcard-llm.sh`(Android)。 | | `packaging/` | 发布包的 cargo-packager 配置(`release.json`)、应用图标(`icons/`、`make_icons.py`)以及 macOS `Info.plist` 补充项和 entitlements。 | @@ -276,6 +276,8 @@ python3 scripts/upstream.py catalog # report drift python3 scripts/upstream.py catalog --apply # rewrite config/apps.json and apps.makepad.json ``` +只有 overlay 的 `pick` 中列出的上游应用会进入 `config/apps.json`:上游之后新增的应用在被挑选之前不会出现,上游不再策展的挑选项会被报告。`--apps config/apps.makepad.json` 会以全部应用启动桌面。 + ### AppCard 助手 AppCard **目前不随产品发布**:它会干扰其他应用,因此除非显式要求,任何构建都不链接它。默认构建、`mobile-apps`、Android 和 iOS 构建都不包含它的界面(octos 内核服务仍然存在),也没有它的磁贴、分组或启动器条目。使用 `--features app-appcard` 可在任意目标平台上恢复它(手机构建请把该 feature 传给 `cargo makepad`)。 diff --git a/desktop/config/apps.json b/desktop/config/apps.json index ce0dad68..44b35b4a 100644 --- a/desktop/config/apps.json +++ b/desktop/config/apps.json @@ -34,14 +34,6 @@ "bin": "terminal", "policy": "new" }, - { - "id": "mixer", - "label": "Mixer", - "source": "makepad", - "package": "makepad-mixer", - "bin": "makepad-mixer", - "policy": "focus" - }, { "id": "task", "label": "Task Manager", @@ -106,30 +98,6 @@ "bin": "calculator", "policy": "focus" }, - { - "id": "fabric", - "label": "Fabric", - "source": "makepad", - "package": "makepad-fabric", - "bin": "makepad-fabric", - "policy": "focus" - }, - { - "id": "score", - "label": "Score", - "source": "makepad", - "package": "makepad-app-score", - "bin": "makepad-app-score", - "policy": "focus" - }, - { - "id": "video", - "label": "Video Player", - "source": "makepad", - "package": "makepad-video", - "bin": "video", - "policy": "new" - }, { "id": "route", "label": "Route", @@ -138,30 +106,6 @@ "bin": "route", "policy": "focus" }, - { - "id": "vj", - "label": "VJ", - "source": "makepad", - "package": "makepad-vj", - "bin": "makepad-vj", - "policy": "focus" - }, - { - "id": "fab", - "label": "Fab", - "source": "makepad", - "package": "makepad-fab", - "bin": "makepad-fab", - "policy": "focus" - }, - { - "id": "studio", - "label": "Director", - "source": "makepad", - "package": "makepad-director", - "bin": "director", - "policy": "focus" - }, { "id": "image", "label": "Image Viewer", diff --git a/desktop/config/apps.makepad.json b/desktop/config/apps.makepad.json index 57c57bd1..ce0dad68 100644 --- a/desktop/config/apps.makepad.json +++ b/desktop/config/apps.makepad.json @@ -90,14 +90,6 @@ "bin": "notes", "policy": "focus" }, - { - "id": "calendar", - "label": "Calendar", - "source": "makepad", - "package": "makepad-calendar", - "bin": "calendar", - "policy": "focus" - }, { "id": "reminders", "label": "Reminders", diff --git a/desktop/config/apps.overlay.json b/desktop/config/apps.overlay.json index 5bce41fb..c60ad1b3 100644 --- a/desktop/config/apps.overlay.json +++ b/desktop/config/apps.overlay.json @@ -1,5 +1,20 @@ { "_": "This project's named adaptations to upstream's curated app registry. Everything else in config/apps.json is generated from the pinned revision by `python3 scripts/upstream.py catalog --apply`.", + "_pick": "The upstream apps OctoSense ships, cherry-picked (2 Oct 2026): the utilities and viewers it keeps, each also a native app (native-apps.json). An app upstream curates that is not picked is not in config/apps.json; config/apps.makepad.json, for --apps, keeps every one.", + "pick": [ + "browser", + "files", + "terminal", + "task", + "sheets", + "clock", + "weather", + "finance", + "notes", + "reminders", + "calculator", + "route" + ], "rows": [ { "id": "reference", diff --git a/desktop/docs/add-all-makepad-apps.md b/desktop/docs/add-all-makepad-apps.md index f38d736e..a093f991 100644 --- a/desktop/docs/add-all-makepad-apps.md +++ b/desktop/docs/add-all-makepad-apps.md @@ -42,7 +42,7 @@ | 文件 | 内容 | |---|---| | `config/apps.json`(默认) | Reference + Makepad 全家桶(20+ 个应用),桌面默认就读它 | -| `config/apps.makepad.json` | 与 `config/apps.json` 相同的副本,供 `--apps` 显式指定 | +| `config/apps.makepad.json` | 上游注册表策展的全部 Makepad 应用,供 `--apps` 显式指定(默认的 `config/apps.json` 只含 `apps.overlay.json` 中 `pick` 挑选的应用) | 默认目录已经包含所有 Makepad 应用,你**不需要手写**,直接 `cargo run --release` 即可。 也可以显式指定副本: diff --git a/desktop/scripts/test_upstream.py b/desktop/scripts/test_upstream.py index 773158a5..4c20acdc 100644 --- a/desktop/scripts/test_upstream.py +++ b/desktop/scripts/test_upstream.py @@ -704,6 +704,39 @@ def test_helper_rows_follow_the_curated_menu(self): ) self.assertEqual([row["id"] for row in merged], ["reference", "browser", "pdf"]) + def test_only_the_picked_upstream_apps_ship_and_the_full_catalog_keeps_the_rest(self): + """Upstream's apps are cherry-picked: one upstream curates later + stays out of the shipped catalog until it is picked, and the full + catalog (`--apps config/apps.makepad.json`) still has every one, + adapted the same way.""" + upstream_rows = [ + {"id": "browser", "label": "Browser", "source": "makepad", + "package": "makepad-browser", "bin": "browser", "policy": "focus"}, + {"id": "studio", "label": "Studio", "source": "makepad", + "package": "makepad-studio", "bin": "studio", "policy": "focus"}, + {"id": "scope", "label": "Scope", "source": "makepad", + "package": "makepad-scope", "bin": "scope", "policy": "focus"}, + ] + overlay = dict(OVERLAY, pick=["browser"]) + shipped = upstream.merge_catalog(upstream_rows, overlay) + self.assertEqual([row["id"] for row in shipped], ["reference", "browser"]) + full = upstream.merge_catalog(upstream_rows, overlay, picked=False) + self.assertEqual([row["id"] for row in full], ["reference", "browser", "studio"]) + self.assertEqual(next(row for row in full if row["id"] == "studio")["label"], "Director") + + +class PickProblems(unittest.TestCase): + def test_a_pick_that_cannot_take_effect_is_named(self): + upstream_rows = [{"id": "browser"}, {"id": "scope"}] + overlay = {"pick": ["browser", "scope", "gone"], "drop": ["scope"]} + problems = upstream.pick_problems(upstream_rows, overlay) + self.assertEqual([(p["id"], p["detail"]) for p in problems], [ + ("scope", "picked and dropped"), + ("gone", "picked, but upstream's registry does not curate it"), + ]) + self.assertEqual(upstream.pick_problems(upstream_rows, {}), [], "nothing picked: nothing to name") + + class CatalogProblems(unittest.TestCase): def test_rows_naming_a_crate_the_revision_does_not_build_are_reported(self): """Upstream's registry can name a binary its own revision no longer @@ -765,13 +798,15 @@ def test_shared_runtime_path_override_is_found(self): class ShippedCatalog(unittest.TestCase): def test_the_shipped_catalog_matches_the_pinned_revision(self): - """The generated rows are the ones in the tree. Drift here means the - catalog and the revision disagree about what the launcher can start, - which is the duplication this generation exists to remove.""" + """The generated rows are the ones in the tree, the shipped catalog + and the full one. Drift here means a catalog and the revision + disagree about what the launcher can start, which is the duplication + this generation exists to remove.""" root = Path(upstream.__file__).resolve().parents[1] try: - generated, problems = upstream.generated_catalog(root) + catalogs, problems = upstream.generated_catalogs(root) except upstream.SyncError as error: self.skipTest(f"pinned checkout unavailable: {error}") self.assertEqual(problems, []) - self.assertEqual(json.loads((root / "config/apps.json").read_text()), generated) + for name, generated in zip(upstream.CATALOGS, catalogs): + self.assertEqual(json.loads((root / name).read_text()), generated, name) diff --git a/desktop/scripts/upstream.py b/desktop/scripts/upstream.py index 1a671df6..540159db 100644 --- a/desktop/scripts/upstream.py +++ b/desktop/scripts/upstream.py @@ -219,18 +219,24 @@ def package_binaries(checkout): } -def merge_catalog(rows, overlay): +def merge_catalog(rows, overlay, picked=True): """Upstream's curated rows under this project's own adaptations. - Adaptations are named, never silent: `drop` removes a row this project - does not ship, `overrides` changes what an id runs while keeping the id - itself, and `rows` are this project's own apps, which lead the menu. + Adaptations are named, never silent: `pick` is the upstream apps this + project ships, cherry-picked by id (an app upstream curates later stays + out until it is picked; without `pick`, every row upstream curates is + taken), `drop` removes a row this project does not ship whatever is + picked (an id an OctoSense app takes), `overrides` changes what an id + runs while keeping the id itself, and `rows` are this project's own + apps, which lead the menu. `picked=False` leaves `pick` out: the full + catalog `--apps config/apps.makepad.json` loads. """ dropped = set(overlay.get("drop", ())) + pick = set(overlay["pick"]) if picked and "pick" in overlay else None overrides = overlay.get("overrides", {}) merged = list(overlay.get("rows", ())) for row in rows: - if row["id"] in dropped: + if row["id"] in dropped or (pick is not None and row["id"] not in pick): continue adapted = dict(row) adapted.update(overrides.get(row["id"], {})) @@ -239,6 +245,20 @@ def merge_catalog(rows, overlay): return merged +def pick_problems(upstream_rows, overlay): + """Picked ids upstream does not curate (gone, or never there) and picked + ids that are also dropped: a pick that cannot take effect is named.""" + curated = {row["id"] for row in upstream_rows} + dropped = set(overlay.get("drop", ())) + problems = [] + for identifier in overlay.get("pick", ()): + if identifier in dropped: + problems.append({"id": identifier, "detail": "picked and dropped"}) + elif identifier not in curated: + problems.append({"id": identifier, "detail": "picked, but upstream's registry does not curate it"}) + return problems + + def catalog_problems(rows, packages): """Rows whose package or binary is not in the pinned revision. @@ -769,12 +789,15 @@ def review_branch(): return report +# The shipped catalog (the apps picked) and the full one (`--apps +# config/apps.makepad.json`: every app upstream curates, adapted the same way). CATALOGS = ("config/apps.json", "config/apps.makepad.json") OVERLAY_PATH = "config/apps.overlay.json" -def generated_catalog(root): - """The catalog this project should ship for the revision it pins. +def generated_catalogs(root): + """The catalogs this project should ship for the revision it pins, in + `CATALOGS` order, and what is wrong with them. Upstream's registry is read out of the checkout Cargo already fetched, so regenerating needs no second clone and cannot describe a revision @@ -784,8 +807,18 @@ def generated_catalog(root): checkout = pinned_checkout(root) registry = (checkout / "apps/wm/src/clients.rs").read_text(errors="replace") overlay = json.loads((root / OVERLAY_PATH).read_text()) - rows = merge_catalog(curated_apps(registry), overlay) - return rows, catalog_problems(rows, package_binaries(checkout)) + upstream = curated_apps(registry) + shipped = merge_catalog(upstream, overlay) + full = merge_catalog(upstream, overlay, picked=False) + packages = package_binaries(checkout) + problems = pick_problems(upstream, overlay) + catalog_problems(full, packages) + return (shipped, full), problems + + +def generated_catalog(root): + """The shipped catalog (`config/apps.json`) and its problems.""" + (shipped, _full), problems = generated_catalogs(root) + return shipped, problems def format_catalog_drift(current, generated, problems): @@ -849,14 +882,17 @@ def main(argv=None): parser.error("update requires --to") try: if args.command == "catalog": - generated, problems = generated_catalog(args.root) - current = json.loads((args.root / CATALOGS[0]).read_text()) - print(format_catalog_drift(current, generated, problems), end="") + catalogs, problems = generated_catalogs(args.root) + for index, (name, generated) in enumerate(zip(CATALOGS, catalogs)): + current = json.loads((args.root / name).read_text()) + print(f"{name}:") + # The problems once, under the first catalog. + print(format_catalog_drift(current, generated, problems if index == 0 else []), end="") if problems: print("Refusing to write a catalog with rows that cannot start.", file=sys.stderr) return 1 if args.apply: - for name in CATALOGS: + for name, generated in zip(CATALOGS, catalogs): (args.root / name).write_text(json.dumps(generated, indent=2) + "\n") print(f"Wrote {', '.join(CATALOGS)}.") return 0 From 603e28afb6490846f9371e6a977fa59ccf222bef Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:04:32 -0700 Subject: [PATCH 2/3] Calculator, Clock, Notes, Reminders and Weather become native apps Five of the Makepad apps the desktop keeps become native apps (native-apps.json). Each is linked as a module: on by default in the desktop shell, off on the phone for now. Each declares its tools verbatim from its ai.rs. - agent.system_tools: the app's own read tools the system agent may call (Calculator's eval, Clock's now, Notes' search and read, Reminders' due and list, Weather's current). tools/native_apps.py checks that each one is the app's own, shareable and read-only. The system chat's grants add them for the apps this build links. - The relay sends any native app's own tools to its AI bus service in the open instance (serves_on_bus), not only the Terminal's. - The assistant pane's own process (aichat) is no longer listed as an app: the dock and the launcher leave it out (apps::listed). - A test path for the system agent's calls: the remote's /event?data=system-call: runs a declared read tool through the relay and logs the reply. - AGENTS.md, the READMEs and the code walkthroughs describe them. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- Cargo.lock | 73 ++++++++++++ Cargo.toml | 10 ++ README.md | 2 +- README.zh-CN.md | 2 +- crates/ai-host/src/native_agents.rs | 5 + crates/ai-host/src/tests.rs | 20 ++-- crates/process-apps/Cargo.toml | 5 + crates/shell/Cargo.toml | 10 ++ crates/shell/src/apps.rs | 22 ++++ crates/shell/src/host_tools/mod.rs | 32 ++++++ crates/shell/src/host_tools/relay.rs | 15 ++- crates/shell/src/host_tools/tests.rs | 49 +++++++- crates/shell/src/lib.rs | 13 +++ crates/shell/src/mobile_tiles.rs | 2 +- crates/shell/src/native_apps.rs | 149 +++++++++++++++++++++++++ crates/shell/src/sandbox/tests.rs | 1 + crates/shell/src/shell/launcher.rs | 2 +- crates/shell/src/system_chat/grants.rs | 16 ++- crates/shell/src/system_chat/tests.rs | 17 +++ desktop/Cargo.toml | 7 +- desktop/README.md | 2 +- desktop/README.zh-CN.md | 2 +- desktop/docs/code-walkthrough.md | 2 +- desktop/docs/code-walkthrough.zh-CN.md | 2 +- native-apps.json | 117 +++++++++++++++++++ tools/native_apps.py | 24 +++- tools/test_native_apps.py | 42 +++++-- 28 files changed, 605 insertions(+), 40 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index cda29a70..3ade0aa7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,7 +25,7 @@ | A system app's agent: its tools and the cards they publish | `apps//bundle/tools.json` and the manifest's `agent` block; the tools run on the app's host service | its host service's tests (below); then in a shell, with the system agent asking the app's agent | | A host service (`mail`, `calendar`, `news`, `llm`, `model`) | `apps/mail/host-service/`, `apps/calendar/host-service/`, `apps/news/host-service/`, `apps/ai-providers/` (`llm` and `model.complete`) | `cargo test --locked -p octosense-mail-service -p octosense-calendar-service -p octosense-news-service -p octosense-llm-service` | | AppCard (opt-in) | `apps/appcard/` | [apps/appcard/AGENTS.md](apps/appcard/AGENTS.md), `apps.yml` | -| A native app (App Hub, Rinx, Terminal, Sheets, Reference, AppCard): its crate, pin, features, hosting per target, sandbox, storage and agent grants | `native-apps.json` only; `python3 tools/native_apps.py` writes the marked blocks in the `Cargo.toml`s, `crates/shell/src/native_apps.rs` and `Cargo.lock` | `python3 tools/native_apps.py --check`, `python3 -m unittest discover -s tools -p 'test_*.py'` | +| A native app (App Hub, Rinx, Terminal, Sheets, Reference, AppCard, and the Makepad apps made native: Calculator, Clock, Notes, Reminders, Weather): its crate, pin, features, hosting per target, sandbox, storage, its tools and which of them the system agent may call (`agent.system_tools`) | `native-apps.json` only; `python3 tools/native_apps.py` writes the marked blocks in the `Cargo.toml`s, `crates/shell/src/native_apps.rs` and `Cargo.lock` | `python3 tools/native_apps.py --check`, `python3 -m unittest discover -s tools -p 'test_*.py'` | | An external pin (Makepad, OctoScript, App Hub, octos, Rinx) | root `Cargo.toml` `[workspace.dependencies]` (a native app's in `native-apps.json`), `native-runtime.lock.json`, `runtime-patches.lock.json` | `python3 tools/setup.py --update`, then `--check --cargo` and `python3 tools/native_apps.py --check` | | A decision | `docs/adr/` (next free number) | — | diff --git a/Cargo.lock b/Cargo.lock index 2ea76387..b23236cf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5290,6 +5290,33 @@ version = "1.0.0" name = "makepad-byteorder-lite" version = "0.1.0" +[[package]] +name = "makepad-calculator" +version = "0.1.0" +dependencies = [ + "makepad-ai-services", + "makepad-app-module", + "makepad-strict-json", + "makepad-widgets", + "makepad-wm-theme", +] + +[[package]] +name = "makepad-civil-time" +version = "0.1.0" + +[[package]] +name = "makepad-clock" +version = "0.1.0" +dependencies = [ + "makepad-ai-services", + "makepad-aichat", + "makepad-app-module", + "makepad-widgets", + "makepad-wm-api", + "makepad-wm-theme", +] + [[package]] name = "makepad-code-editor" version = "2.0.0" @@ -5522,6 +5549,18 @@ dependencies = [ "windows 0.62.2", ] +[[package]] +name = "makepad-notes" +version = "0.1.0" +dependencies = [ + "makepad-ai-services", + "makepad-app-module", + "makepad-civil-time", + "makepad-strict-json", + "makepad-widgets", + "makepad-wm-theme", +] + [[package]] name = "makepad-objc-sys" version = "1.0.0" @@ -5583,6 +5622,18 @@ dependencies = [ name = "makepad-regex" version = "0.1.0" +[[package]] +name = "makepad-reminders" +version = "0.1.0" +dependencies = [ + "makepad-ai-services", + "makepad-app-module", + "makepad-civil-time", + "makepad-strict-json", + "makepad-widgets", + "makepad-wm-theme", +] + [[package]] name = "makepad-script" version = "1.0.0" @@ -5693,6 +5744,18 @@ dependencies = [ "makepad-live-id", ] +[[package]] +name = "makepad-weather" +version = "0.1.0" +dependencies = [ + "makepad-ai-services", + "makepad-aichat", + "makepad-app-module", + "makepad-widgets", + "makepad-wm-api", + "makepad-wm-theme", +] + [[package]] name = "makepad-webp" version = "0.2.4" @@ -7668,8 +7731,13 @@ dependencies = [ name = "octosense-process-apps" version = "0.1.0" dependencies = [ + "makepad-calculator", + "makepad-clock", + "makepad-notes", + "makepad-reminders", "makepad-sheets", "makepad-terminal", + "makepad-weather", "octosense-reference", ] @@ -7691,11 +7759,16 @@ dependencies = [ "makepad-ai-services", "makepad-aichat", "makepad-app-module", + "makepad-calculator", + "makepad-clock", "makepad-network", + "makepad-notes", + "makepad-reminders", "makepad-sheets", "makepad-strict-json", "makepad-studio-protocol", "makepad-terminal", + "makepad-weather", "makepad-widgets", "makepad-wm-api", "makepad-wm-theme", diff --git a/Cargo.toml b/Cargo.toml index 33a9e66c..4f9035d6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -70,6 +70,11 @@ makepad-sheets = { git = "https://github.com/OctoSense-org/makepad.git", rev = " makepad-terminal = { git = "https://github.com/OctoSense-org/makepad.git", rev = "c155f61d0e1600d2ec474209374444a38a09a470" } octosense-appcard = { path = "apps/appcard/module" } octosense-app-hub-app = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "0d5b47a2ae9eb98020feca26b7c895a3cf797dc1" } +makepad-calculator = { git = "https://github.com/OctoSense-org/makepad.git", rev = "c155f61d0e1600d2ec474209374444a38a09a470", default-features = false } +makepad-clock = { git = "https://github.com/OctoSense-org/makepad.git", rev = "c155f61d0e1600d2ec474209374444a38a09a470", default-features = false } +makepad-notes = { git = "https://github.com/OctoSense-org/makepad.git", rev = "c155f61d0e1600d2ec474209374444a38a09a470", default-features = false } +makepad-reminders = { git = "https://github.com/OctoSense-org/makepad.git", rev = "c155f61d0e1600d2ec474209374444a38a09a470", default-features = false } +makepad-weather = { git = "https://github.com/OctoSense-org/makepad.git", rev = "c155f61d0e1600d2ec474209374444a38a09a470", default-features = false } # END native-apps: pins # --- Makepad (OctoSense-org/makepad), the revision Octoscript-Makepad's # runtime.json pins (native-runtime.lock.json); resolved to .sources/makepad. @@ -236,6 +241,11 @@ makepad-markdown-widgets = { path = ".sources/makepad/libs/markdown_widgets" } # BEGIN native-apps: patch https://github.com/OctoSense-org/makepad.git (generated by tools/native_apps.py from native-apps.json; do not edit) makepad-sheets = { path = ".sources/makepad/apps/sheets" } makepad-terminal = { path = ".sources/makepad/apps/terminal" } +makepad-calculator = { path = ".sources/makepad/apps/calculator" } +makepad-clock = { path = ".sources/makepad/apps/clock" } +makepad-notes = { path = ".sources/makepad/apps/notes" } +makepad-reminders = { path = ".sources/makepad/apps/reminders" } +makepad-weather = { path = ".sources/makepad/apps/weather" } # END native-apps: patch https://github.com/OctoSense-org/makepad.git # AppCard's inline-renderer kits name makepad-widgets by the ZhangHanDong URL. diff --git a/README.md b/README.md index d5ef9f90..13a8a561 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ The person talks to it in the shell's **assistant pane**, the system chat (`crat Its kernel tools are exactly `SYSTEM_AGENT_TOOLS` (`crates/kernel/src/system_tools.rs`): `peer_send_input`, `peer_gather`, `peer_list` and `peer_respond` to supervise app agents, its workspace's file tools, memory, `ask_user_question`, media viewing, `web_search`, `web_fetch` and `tool_search`. Every kernel start sets that list with octos's `session/tool_list/set`, so octos's own shell, the spawn family and `peer_close` are never offered. -The system chat also registers host tools on the session: `agents.list` and `agents.ask` (`crates/shell/src/agents.rs`: which apps have an agent, and the first-use sheet and ready-peer wait for one), and `terminal.run` while Setup › Assistant › Command execution is on. +The system chat also registers host tools on the session: `agents.list` and `agents.ask` (`crates/shell/src/agents.rs`: which apps have an agent, and the first-use sheet and ready-peer wait for one), `terminal.run` while Setup › Assistant › Command execution is on, and the native apps' own read tools their `native-apps.json` entries name (`agent.system_tools`): Calculator's `calculator.eval`, Clock's `clock.now`, Notes' `notes.search` and `notes.read`, Reminders' `reminders.due` and `reminders.list`, Weather's `weather.current`. Such a call reaches the app's open instance (its AI bus service); a closed app answers "Open Notes first". **An app agent** is one host-owned octos peer per (app, account) ([`crates/app-peers`](crates/app-peers/README.md)). These apps have one on `main` (`crates/shell/src/apps.rs`, `agent_apps`): diff --git a/README.zh-CN.md b/README.zh-CN.md index 43011c79..558498bf 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -89,7 +89,7 @@ flowchart LR 它的内核工具恰好是 `SYSTEM_AGENT_TOOLS`(`crates/kernel/src/system_tools.rs`):用于监督应用 Agent 的 `peer_send_input`、`peer_gather`、`peer_list` 和 `peer_respond`,其工作区的文件工具,记忆,`ask_user_question`,媒体查看,`web_search`、`web_fetch` 和 `tool_search`。每次内核启动都用 octos 的 `session/tool_list/set` 设置这份列表,所以 octos 自带的 shell、spawn 一族和 `peer_close` 永远不会提供给它。 -系统对话还会在这个会话上注册宿主工具:`agents.list` 和 `agents.ask`(`crates/shell/src/agents.rs`:哪些应用有 Agent,以及某个 Agent 的首次使用面板与就绪等待),以及在 Setup › Assistant › Command execution 打开期间的 `terminal.run`。 +系统对话还会在这个会话上注册宿主工具:`agents.list` 和 `agents.ask`(`crates/shell/src/agents.rs`:哪些应用有 Agent,以及某个 Agent 的首次使用面板与就绪等待),在 Setup › Assistant › Command execution 打开期间的 `terminal.run`,以及原生应用在 `native-apps.json` 条目中列出的只读工具(`agent.system_tools`):Calculator 的 `calculator.eval`、Clock 的 `clock.now`、Notes 的 `notes.search` 和 `notes.read`、Reminders 的 `reminders.due` 和 `reminders.list`、Weather 的 `weather.current`。这样的调用会到达该应用已打开的实例(它的 AI bus 服务);应用未打开时会回答“Open Notes first”。 **应用 Agent** 是每个(应用,账号)一个由宿主拥有的 octos peer([`crates/app-peers`](crates/app-peers/README.md))。`main` 上以下应用有 Agent(`crates/shell/src/apps.rs`,`agent_apps`): diff --git a/crates/ai-host/src/native_agents.rs b/crates/ai-host/src/native_agents.rs index 2267c1bd..58655c6a 100644 --- a/crates/ai-host/src/native_agents.rs +++ b/crates/ai-host/src/native_agents.rs @@ -7,4 +7,9 @@ /// (app id, the `octos.*` services its agent is granted). pub const NATIVE_AGENTS: &[(&str, &[&str])] = &[ ("rinx", &["octos.session.open", "octos.session.history", "octos.turn.start", "octos.turn.interrupt"]), + ("calculator", &["octos.session.open", "octos.session.history", "octos.turn.start", "octos.turn.interrupt"]), + ("clock", &["octos.session.open", "octos.session.history", "octos.turn.start", "octos.turn.interrupt"]), + ("notes", &["octos.session.open", "octos.session.history", "octos.turn.start", "octos.turn.interrupt"]), + ("reminders", &["octos.session.open", "octos.session.history", "octos.turn.start", "octos.turn.interrupt"]), + ("weather", &["octos.session.open", "octos.session.history", "octos.turn.start", "octos.turn.interrupt"]), ]; diff --git a/crates/ai-host/src/tests.rs b/crates/ai-host/src/tests.rs index 7e2dd150..f5553edd 100644 --- a/crates/ai-host/src/tests.rs +++ b/crates/ai-host/src/tests.rs @@ -104,23 +104,25 @@ fn rinx_is_granted_before_start() { assert!(offered.finish().is_some()); } -/// The shipped policy grants Rinx exactly the assistant services, and it -/// comes from `native-apps.json` (the generated `native_agents`), not code. +/// The shipped policy grants each native app the agent services its +/// `native-apps.json` entry declares (the generated `native_agents`), not +/// code: Rinx exactly the assistant services. #[test] -fn the_shipped_policy_grants_rinx_the_assistant() { +fn the_shipped_policy_grants_each_native_app_its_declared_services() { let policy = Policy::shipped(); let grants: Vec<_> = policy.grants().collect(); - assert_eq!(grants.len(), 1); - assert_eq!(grants[0].0, "rinx"); - assert_eq!(grants[0].1, octosense_app_peers::OCTOS_SERVICES.map(String::from)); + let rinx = grants.iter().find(|(app, _)| *app == "rinx").expect("Rinx is granted"); + assert_eq!(rinx.1, octosense_app_peers::OCTOS_SERVICES.map(String::from)); assert!(Policy::none().grants().next().is_none()); let generated: Vec<(&str, Vec)> = crate::native_agents::NATIVE_AGENTS.iter().map(|(app, s)| (*app, s.iter().map(|s| s.to_string()).collect())).collect(); let shipped: Vec<(&str, Vec)> = policy.grants().map(|(app, s)| (app, s.to_vec())).collect(); assert_eq!(shipped, generated, "Policy::shipped() is the manifest's agent block"); let manifest: serde_json::Value = serde_json::from_str(include_str!("../../../native-apps.json")).unwrap(); - let rinx = manifest["apps"].as_array().unwrap().iter().find(|a| a["id"] == "rinx").unwrap(); - let declared: Vec = rinx["agent"]["octos"].as_array().unwrap().iter().map(|s| s.as_str().unwrap().to_string()).collect(); - assert_eq!(grants[0].1, declared); + for (app, services) in &grants { + let entry = manifest["apps"].as_array().unwrap().iter().find(|a| a["id"] == *app).unwrap(); + let declared: Vec = entry["agent"]["octos"].as_array().unwrap().iter().map(|s| s.as_str().unwrap().to_string()).collect(); + assert_eq!(services.to_vec(), declared, "{app}"); + } } #[test] diff --git a/crates/process-apps/Cargo.toml b/crates/process-apps/Cargo.toml index 4d6d9f3f..97e30173 100644 --- a/crates/process-apps/Cargo.toml +++ b/crates/process-apps/Cargo.toml @@ -20,4 +20,9 @@ path = "src/lib.rs" octosense-reference = { workspace = true } makepad-sheets = { workspace = true, features = ["standalone"] } makepad-terminal = { workspace = true } +makepad-calculator = { workspace = true, features = ["standalone"] } +makepad-clock = { workspace = true, features = ["standalone"] } +makepad-notes = { workspace = true, features = ["standalone"] } +makepad-reminders = { workspace = true, features = ["standalone"] } +makepad-weather = { workspace = true, features = ["standalone"] } # END native-apps: process deps diff --git a/crates/shell/Cargo.toml b/crates/shell/Cargo.toml index 54a2292f..d38631c1 100644 --- a/crates/shell/Cargo.toml +++ b/crates/shell/Cargo.toml @@ -74,6 +74,11 @@ makepad-sheets = { workspace = true, optional = true } makepad-terminal = { workspace = true, optional = true } octosense-appcard = { workspace = true, optional = true } octosense-app-hub-app = { workspace = true, optional = true } +makepad-calculator = { workspace = true, optional = true } +makepad-clock = { workspace = true, optional = true } +makepad-notes = { workspace = true, optional = true } +makepad-reminders = { workspace = true, optional = true } +makepad-weather = { workspace = true, optional = true } # END native-apps: deps # The host secrets API keeps apps' secrets in the keychain (app_storage/secrets.rs). @@ -116,6 +121,11 @@ app-sheets = ["dep:makepad-sheets"] app-terminal = ["dep:makepad-terminal"] app-appcard = ["dep:octosense-appcard", "octos-core"] app-hub = ["dep:octosense-app-hub-app", "dep:octosense-appstore", "dep:octosense-app-policy", "dep:octosense-app-hub", "dep:octosense-app-contract", "dep:octosense-mail-service", "dep:octosense-news-service", "dep:octosense-calendar-service", "octosense-ai-host/llm"] +app-calculator = ["dep:makepad-calculator"] +app-clock = ["dep:makepad-clock"] +app-notes = ["dep:makepad-notes"] +app-reminders = ["dep:makepad-reminders"] +app-weather = ["dep:makepad-weather"] # END native-apps: features # The aichat module, seated in the pane in-process. app-aichat = ["dep:makepad-aichat"] diff --git a/crates/shell/src/apps.rs b/crates/shell/src/apps.rs index 64b30f32..5d92c0f8 100644 --- a/crates/shell/src/apps.rs +++ b/crates/shell/src/apps.rs @@ -462,6 +462,17 @@ pub fn catalog_visible(id: &str) -> bool { !matches!(id, "card" | "appstore") } +/// Registry rows a shell surface launches but no list shows as an app: +/// `aichat` is the assistant pane's own process (F10), which the pane +/// starts (`clients::find_app("aichat")`); the dock and the launcher do not. +pub const PANE_ONLY: &[&str] = &["aichat"]; + +/// Whether a registry row is listed to the person and the system agent: +/// the launcher, the dock, the phone's home, the `os` app list. +pub fn listed(id: &str) -> bool { + catalog_visible(id) && !PANE_ONLY.contains(&id) +} + /// The linked modules as launcher rows. pub fn bundled_modules_catalog() -> Vec { linked_modules() @@ -650,6 +661,17 @@ impl AppRegistry { #[cfg(test)] mod tests { + /// The assistant pane's own process is a registry row (F10 starts it + /// from there) but no list shows it as an app; the apps people pick + /// are listed. + #[test] + fn the_panes_own_process_is_never_listed_as_an_app() { + assert!(!listed("aichat")); + assert!(!listed("card") && !listed("appstore")); + assert!(listed("notes") && listed("terminal") && listed("os.mail")); + assert!(crate::shell::launcher::apps().iter().all(|a| a.id != "apps.aichat" && a.id != "aichat")); + } + use super::*; /// ADR 0004 §3: no script app takes a native app's id or namespace. diff --git a/crates/shell/src/host_tools/mod.rs b/crates/shell/src/host_tools/mod.rs index c1075ba1..c982358b 100644 --- a/crates/shell/src/host_tools/mod.rs +++ b/crates/shell/src/host_tools/mod.rs @@ -195,6 +195,38 @@ pub fn system_call(mut call: HostToolCall, reply: ToolReply) { submit(Event::Call { call, reply }); } +/// A test path, the remote's `/event?data=system-call: `: +/// the system agent's call to `tool` as its session makes it, through the +/// relay (its grants, the tool's schema, the owning app's bus service), +/// with the reply logged as `[system-call] `. Only a declared +/// read tool: a test call never acts. +pub fn system_call_test(spec: &str) { + use std::sync::atomic::{AtomicU64, Ordering}; + static NEXT: AtomicU64 = AtomicU64::new(1); + let (tool, args) = spec.split_once(' ').unwrap_or((spec, "{}")); + let read = owner_of(tool).and_then(|owner| declaration(&owner, tool)).is_some_and(|d| d["risk"] == "read"); + if !read { + makepad_widgets::log!("[system-call] {tool}: a test call runs only a declared read tool"); + return; + } + let Ok(args) = serde_json::from_str::(args) else { + makepad_widgets::log!("[system-call] {tool}: the arguments are not JSON"); + return; + }; + let id = format!("system-call-{}", NEXT.fetch_add(1, Ordering::Relaxed)); + let params = serde_json::json!({ + "session_id": crate::system_chat::session::SYSTEM_SESSION, "turn_id": format!("turn-{id}"), + "call_id": id, "tool_call_id": format!("tc-{id}"), "args_digest": "test", + "name": tool, "caller": {"kind": "system"}, "args": args, "risk": "read", "confirm_required": false, + }); + let Ok(call) = HostToolCall::parse(¶ms) else { + makepad_widgets::log!("[system-call] {tool}: could not form the call"); + return; + }; + let name = tool.to_string(); + system_call(call, ToolReply::new(id, move |reply| makepad_widgets::log!("[system-call] {name} {reply}"))); +} + pub fn system_cancel(call_id: &str) { submit(Event::Cancel { call_id: call_id.to_string(), reason: "cancelled".into() }); } diff --git a/crates/shell/src/host_tools/relay.rs b/crates/shell/src/host_tools/relay.rs index b15ed96f..24371ff5 100644 --- a/crates/shell/src/host_tools/relay.rs +++ b/crates/shell/src/host_tools/relay.rs @@ -57,6 +57,14 @@ pub const SYSTEM: &str = "system"; pub const TERMINAL_RUN: &str = "terminal.run"; /// The Terminal, whose tools the relay runs on the AI bus. pub const TERMINAL_APP: &str = "terminal"; + +/// Whether `owner` is a native app whose own tools (`native-apps.json` +/// `agent.tools`) run on its AI bus service: the Terminal, Calculator, +/// Notes, every native app that declares tools. A closed app answers that +/// it is not running. +pub fn serves_on_bus(owner: &str) -> bool { + crate::native_apps::find(owner).is_some_and(|app| app.tools_json != "[]") +} /// Developer mode's command tool (§13), run by the shell ([`super::dev_run`]). pub const DEV_RUN: &str = "dev.run"; /// The executor key of the tools the shell itself runs for an app's own @@ -749,9 +757,10 @@ impl Relay { } let target = if self.executors.contains_key(&owner) { Target::Executor(owner.clone()) - } else if owner == TERMINAL_APP { - // The Terminal's tools run on its AI bus service, in the - // terminal the person sees. + } else if serves_on_bus(&owner) { + // A native app's own tools run on its AI bus service, in the + // instance the person has open: the terminal they see, the + // notes they keep. Target::Bus } else { return refuse(&reply, "app_not_running", format!("{} isn't running", crate::approvals::sheet::app_label(&owner))); diff --git a/crates/shell/src/host_tools/tests.rs b/crates/shell/src/host_tools/tests.rs index 95f950e0..bf98685b 100644 --- a/crates/shell/src/host_tools/tests.rs +++ b/crates/shell/src/host_tools/tests.rs @@ -406,11 +406,12 @@ fn a_host_tool_approval_whose_turn_ended_is_withdrawn_from_the_sheet() { #[test] fn an_app_that_is_not_running_is_refused_visibly() { + // An app with neither an executor nor a native app's bus service. let mut relay = Relay::default(); - relay.catalog.declare("notes", vec![decl("notes.add", false, "host")]); + relay.catalog.declare("jot", vec![decl("jot.add", false, "host")]); let mut w = World::new(FixedDevMode::off()); let (r, sent) = reply("c1"); - relay.handle(Event::Call { call: call("c1", "notes.add", "notes"), reply: r }, &mut w); + relay.handle(Event::Call { call: call("c1", "jot.add", "jot"), reply: r }, &mut w); assert_eq!(sent.lock().unwrap()[0]["error"]["kind"], "app_not_running"); let _ = Decision::Deny; } @@ -421,7 +422,7 @@ fn the_shipped_catalog_offers_the_terminals_run_to_those_granted_it() { let run = catalog.entry("terminal", TERMINAL_RUN).unwrap(); assert_eq!((run["risk"].as_str(), run["confirm"].as_str()), (Some("destructive"), Some("host"))); assert!(catalog.declarations("rinx", false).is_empty(), "nobody gets it without a grant"); - assert_eq!(catalog.declarations("rinx", true).len(), 3, "developer mode grants every shareable tool"); + assert_eq!(catalog.declarations("rinx", true).len(), shareable_native_tools().len(), "developer mode grants every shareable tool"); } /// G3: the native apps' agent blocks (`native-apps.json`) are the shipped @@ -474,6 +475,32 @@ fn the_terminals_read_tools_are_granted_then_read_on_the_bus() { assert_eq!(sent.lock().unwrap()[0]["data"]["text"], "$ ls"); } +/// A native app's own read tool (Calculator's `eval`) reaches the system +/// agent once granted, and runs on the app's AI bus service by its short +/// name: the Terminal is no longer the only native app the bus serves. +#[test] +fn a_native_apps_read_tool_runs_on_its_bus_service() { + assert!(super::relay::serves_on_bus("terminal") && super::relay::serves_on_bus("calculator") && super::relay::serves_on_bus("notes")); + assert!(!super::relay::serves_on_bus("sheets"), "an app that declares no tools has none to serve"); + assert!(!super::relay::serves_on_bus("os.mail") && !super::relay::serves_on_bus("nowhere")); + let mut relay = Relay::default(); + let mut w = World::new(FixedDevMode::off()); + let mut system = call("c1", "calculator.eval", "system"); + system.args = json!({"expression": "6*7"}); + system.caller_kind = CallerKind::System; + system.origin = CallOrigin::System; + let (r, sent) = reply("c1"); + relay.handle(Event::Call { call: system.clone(), reply: r }, &mut w); + assert_eq!(sent.lock().unwrap()[0]["error"]["kind"], "not_granted"); + w.system.insert("calculator.eval".into()); + system.call_id = "c2".into(); + let (r, sent) = reply("c2"); + relay.handle(Event::Call { call: system, reply: r }, &mut w); + assert_eq!(w.bus, vec![(format!("{BUS_PREFIX}c2"), "calculator".into(), "eval".into(), json!({"expression": "6*7"}).to_string())]); + relay.handle(Event::BusResult { call_id: "c2".into(), outcome: ToolOutcome::Ok(json!({"text": "42"})) }, &mut w); + assert_eq!(sent.lock().unwrap()[0]["ok"], true); +} + /// The system toolbox's tools as its catalog declares them (the real ones /// with the `toolbox-peers` feature): owned by `toolbox`, shareable, read /// except `workflow.fork`. @@ -490,6 +517,17 @@ fn toolbox_catalog() -> Vec { .collect() } +/// Every native app's shareable tool (`native-apps.json`): what developer +/// mode grants another app's agent. +fn shareable_native_tools() -> Vec { + crate::native_apps::APPS + .iter() + .flat_map(|app| serde_json::from_str::>(app.tools_json).unwrap()) + .filter(|tool| tool["shareable"] == true) + .map(|tool| tool["name"].as_str().unwrap().to_string()) + .collect() +} + fn offered_names(relay: &Relay, app: &str, dev: bool, consented: bool) -> Vec { let mut names: Vec = relay.catalog.offered(app, dev, consented).iter().map(|d| d["name"].as_str().unwrap().to_string()).collect(); names.sort(); @@ -516,7 +554,10 @@ fn a_peer_is_offered_exactly_its_granted_toolbox_tools_marked_with_their_owner_a // No grant, no toolbox tools; developer mode does not invent a grant // (the toolbox needs its scope), though it grants other shareable tools. assert!(offered_names(&relay, "calendar", false, true).is_empty()); - assert_eq!(offered_names(&relay, "calendar", true, true), [super::relay::DEV_RUN, "terminal.read_screen", "terminal.read_scrollback", TERMINAL_RUN]); + let mut every = shareable_native_tools(); + every.push(super::relay::DEV_RUN.to_string()); + every.sort(); + assert_eq!(offered_names(&relay, "calendar", true, true), every); } #[test] diff --git a/crates/shell/src/lib.rs b/crates/shell/src/lib.rs index c8e06804..1ae2a82b 100644 --- a/crates/shell/src/lib.rs +++ b/crates/shell/src/lib.rs @@ -2466,6 +2466,7 @@ impl App { fn registry_apps() -> Vec<(String, String)> { clients::available_apps() .iter() + .filter(|a| apps::listed(&a.id)) .map(|a| (a.id.clone(), a.label.clone())) .collect() } @@ -2811,6 +2812,7 @@ impl App { let focused = state.layout.focused_client(); let mut rows: Vec = clients::available_apps() .iter() + .filter(|a| apps::listed(&a.id)) .map(|a| OsAppRow { id: a.id.clone(), label: a.label.clone(), @@ -5524,6 +5526,7 @@ fn os_launch_answer(call_id: &str, label: &str, already_running: bool) -> ToolRe fn known_app_ids() -> String { clients::available_apps() .iter() + .filter(|a| apps::listed(&a.id)) .map(|a| a.id.as_str()) .collect::>() .join(", ") @@ -6696,6 +6699,16 @@ impl App { self.system_chat_changed(cx); return; } + // The remote's `/event?data=system-call: `: a test call + // from the system agent to a granted read tool (host_tools.rs + // `system_call_test`), logged with its reply. + if let Event::Custom(data) = event { + if let Some(spec) = data.strip_prefix("system-call:") { + host_tools::system_call_test(spec); + self.host_tools_pump(cx); + return; + } + } if let Event::Signal = event { if self.state.is_some() { self.system_chat_changed(cx); diff --git a/crates/shell/src/mobile_tiles.rs b/crates/shell/src/mobile_tiles.rs index 73116195..a3eec750 100644 --- a/crates/shell/src/mobile_tiles.rs +++ b/crates/shell/src/mobile_tiles.rs @@ -206,7 +206,7 @@ pub fn home_layout_for_apps(screen: Rect, top: f64, dock: Rect, apps: &[&str]) - /// every launch target is reachable from exactly one card. pub const LIBRARY_GROUPS: [(&str, &[&str]); 4] = [ ("Utilities", &["clock", "weather", "calculator", "appcard", "news", "terminal", "files", "task"]), - ("Productivity", &["notes", "reminders", "sheets", "browser", "route", "maps", "finance", "aichat"]), + ("Productivity", &["notes", "reminders", "sheets", "browser", "route", "maps", "finance"]), ("Media", &["photos", "image", "pdf"]), ("Other", &[]), ]; diff --git a/crates/shell/src/native_apps.rs b/crates/shell/src/native_apps.rs index 5e48afa5..2eb906a7 100644 --- a/crates/shell/src/native_apps.rs +++ b/crates/shell/src/native_apps.rs @@ -85,6 +85,9 @@ pub struct NativeApp { /// `agent.grants`: other apps' shareable tools its agent may call, /// as (owning app, tool). pub grants: &'static [(&'static str, &'static str)], + /// `agent.system_tools`: its own read tools the system agent may + /// call while the app runs (full names). + pub system_tools: &'static [&'static str], /// `agent.budget`: its agent's tool calls per turn and per day /// (`None`: the shell's defaults). pub calls_per_turn: Option, @@ -113,6 +116,7 @@ pub const APPS: &[NativeApp] = &[ tools_json: r##"[]"##, generic_tools: &["read_file", "write_file", "edit_file", "diff_edit", "apply_patch", "glob", "grep", "list_dir", "code_structure", "ask_user_question", "view_image", "recall", "recall_memory", "memory_search", "memory_load", "save_memory", "memory_note", "web_search", "web_fetch", "tool_search"], grants: &[], + system_tools: &[], calls_per_turn: None, calls_per_day: None, }, @@ -137,6 +141,7 @@ pub const APPS: &[NativeApp] = &[ tools_json: r##"[]"##, generic_tools: &[], grants: &[], + system_tools: &[], calls_per_turn: None, calls_per_day: None, }, @@ -161,6 +166,7 @@ pub const APPS: &[NativeApp] = &[ tools_json: r##"[]"##, generic_tools: &[], grants: &[], + system_tools: &[], calls_per_turn: None, calls_per_day: None, }, @@ -187,6 +193,7 @@ pub const APPS: &[NativeApp] = &[ tools_json: r##"[{"name":"terminal.run","description":"Type a command followed by Enter into the person's live Terminal. The person approves each command first, on a sheet that shows it exactly; it then runs for real in the terminal they see, inside the Terminal's own sandbox: the person's files and the network are reachable, OctoSense's private data and the build toolchain are not. It returns at once: the output is on the Terminal's screen.","input_schema":{"type":"object","properties":{"command":{"type":"string","maxLength":4096}},"required":["command"],"additionalProperties":false},"risk":"destructive","confirm":"host","shareable":true},{"name":"terminal.read_screen","description":"Read the terminal grid that is visible now, with trailing spaces removed from each row, plus cursor and working-directory context.","input_schema":{"type":"object","properties":{},"additionalProperties":false},"risk":"read","shareable":true},{"name":"terminal.read_scrollback","description":"Read the last requested number of lines from the terminal's scrollback plus screen (default 200, at most 2000).","input_schema":{"type":"object","properties":{"lines":{"type":"integer","minimum":1,"maximum":2000}},"additionalProperties":false},"risk":"read","shareable":true}]"##, generic_tools: &[], grants: &[], + system_tools: &[], calls_per_turn: None, calls_per_day: None, }, @@ -211,6 +218,7 @@ pub const APPS: &[NativeApp] = &[ tools_json: r##"[]"##, generic_tools: &[], grants: &[], + system_tools: &[], calls_per_turn: None, calls_per_day: None, }, @@ -235,6 +243,132 @@ pub const APPS: &[NativeApp] = &[ tools_json: r##"[]"##, generic_tools: &[], grants: &[], + system_tools: &[], + calls_per_turn: None, + calls_per_day: None, + }, + NativeApp { + id: "calculator", + feature: "app-calculator", + bin: Some("calculator"), + macos: Hosting::Module, + windows: Hosting::Module, + linux: Hosting::Module, + android: Hosting::Module, + ios: Hosting::Module, + ohos: Hosting::Module, + wasm: Hosting::Module, + octos: &["octos.session.open", "octos.session.history", "octos.turn.start", "octos.turn.interrupt"], + tools: &[], + network: Network::None, + processes: false, + accounts: false, + external: &[], + storage: r#"{"accounts": false, "agent_workspace": "none", "external": []}"#, + tools_json: r##"[{"name":"calculator.eval","description":"Evaluate an expression. Uses the on-screen angle mode. Does not change the calculation, memory, or history.","input_schema":{"type":"object","properties":{"expression":{"type":"string","maxLength":1024}},"required":["expression"],"additionalProperties":false},"risk":"read","shareable":true}]"##, + generic_tools: &[], + grants: &[], + system_tools: &["calculator.eval"], + calls_per_turn: None, + calls_per_day: None, + }, + NativeApp { + id: "clock", + feature: "app-clock", + bin: Some("clock"), + macos: Hosting::Module, + windows: Hosting::Module, + linux: Hosting::Module, + android: Hosting::Module, + ios: Hosting::Module, + ohos: Hosting::Module, + wasm: Hosting::Module, + octos: &["octos.session.open", "octos.session.history", "octos.turn.start", "octos.turn.interrupt"], + tools: &[], + network: Network::None, + processes: false, + accounts: false, + external: &[], + storage: r#"{"accounts": false, "agent_workspace": "none", "external": []}"#, + tools_json: r##"[{"name":"clock.now","description":"The current local time and date, the next alarm, and any running timer or stopwatch.","input_schema":{"type":"object","properties":{}},"risk":"read","shareable":true}]"##, + generic_tools: &[], + grants: &[], + system_tools: &["clock.now"], + calls_per_turn: None, + calls_per_day: None, + }, + NativeApp { + id: "notes", + feature: "app-notes", + bin: Some("notes"), + macos: Hosting::Module, + windows: Hosting::Module, + linux: Hosting::Module, + android: Hosting::Module, + ios: Hosting::Module, + ohos: Hosting::Module, + wasm: Hosting::Module, + octos: &["octos.session.open", "octos.session.history", "octos.turn.start", "octos.turn.interrupt"], + tools: &[], + network: Network::None, + processes: false, + accounts: false, + external: &[], + storage: r#"{"accounts": false, "agent_workspace": "none", "external": []}"#, + tools_json: r##"[{"name":"notes.search","description":"Find active notes whose title or body contains the query. Answers id, title, preview, folder, edited_ms and pinned.","input_schema":{"type":"object","properties":{"query":{"type":"string","description":"text to find"}},"required":["query"]},"risk":"read","shareable":true},{"name":"notes.read","description":"Read one active note by id: folder, title, complete source, timestamps and pinned state.","input_schema":{"type":"object","properties":{"id":{"type":"string","description":"note id such as n000001"}},"required":["id"]},"risk":"read","shareable":true}]"##, + generic_tools: &[], + grants: &[], + system_tools: &["notes.search", "notes.read"], + calls_per_turn: None, + calls_per_day: None, + }, + NativeApp { + id: "reminders", + feature: "app-reminders", + bin: Some("reminders"), + macos: Hosting::Module, + windows: Hosting::Module, + linux: Hosting::Module, + android: Hosting::Module, + ios: Hosting::Module, + ohos: Hosting::Module, + wasm: Hosting::Module, + octos: &["octos.session.open", "octos.session.history", "octos.turn.start", "octos.turn.interrupt"], + tools: &[], + network: Network::None, + processes: false, + accounts: false, + external: &[], + storage: r#"{"accounts": false, "agent_workspace": "none", "external": []}"#, + tools_json: r##"[{"name":"reminders.due","description":"Incomplete dated reminders due before today plus N days, including overdue items and today.","input_schema":{"type":"object","properties":{"days":{"type":"integer","minimum":1,"maximum":31}},"required":["days"],"additionalProperties":false},"risk":"read","shareable":true},{"name":"reminders.list","description":"Reminders in a smart list or personal list, using the same projection as the UI.","input_schema":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":64}},"required":["name"],"additionalProperties":false},"risk":"read","shareable":true}]"##, + generic_tools: &[], + grants: &[], + system_tools: &["reminders.due", "reminders.list"], + calls_per_turn: None, + calls_per_day: None, + }, + NativeApp { + id: "weather", + feature: "app-weather", + bin: Some("weather"), + macos: Hosting::Module, + windows: Hosting::Module, + linux: Hosting::Module, + android: Hosting::Module, + ios: Hosting::Module, + ohos: Hosting::Module, + wasm: Hosting::Module, + octos: &["octos.session.open", "octos.session.history", "octos.turn.start", "octos.turn.interrupt"], + tools: &[], + network: Network::Any, + processes: false, + accounts: false, + external: &[], + storage: r#"{"accounts": false, "agent_workspace": "none", "external": []}"#, + tools_json: r##"[{"name":"weather.current","description":"Current conditions and the 5-day forecast for the city selected on screen.","input_schema":{"type":"object","properties":{}},"risk":"read","shareable":true}]"##, + generic_tools: &[], + grants: &[], + system_tools: &["weather.current"], calls_per_turn: None, calls_per_day: None, }, @@ -281,6 +415,11 @@ pub fn package_of(id: &str) -> Option<&'static str> { "reference" => Some("octosense-reference"), "sheets" => Some("makepad-sheets"), "terminal" => Some("makepad-terminal"), + "calculator" => Some("makepad-calculator"), + "clock" => Some("makepad-clock"), + "notes" => Some("makepad-notes"), + "reminders" => Some("makepad-reminders"), + "weather" => Some("makepad-weather"), _ => None, } } @@ -303,4 +442,14 @@ pub fn link(out: &mut Vec<&'static dyn AppModule>) { out.push(&octosense_app_hub_app::APP_HUB_MODULE); out.push(&octosense_app_hub_app::CARD_MODULE); } + #[cfg(feature = "app-calculator")] + out.push(&makepad_calculator::CALCULATOR_MODULE); + #[cfg(feature = "app-clock")] + out.push(&makepad_clock::CLOCK_MODULE); + #[cfg(feature = "app-notes")] + out.push(&makepad_notes::NOTES_MODULE); + #[cfg(feature = "app-reminders")] + out.push(&makepad_reminders::REMINDERS_MODULE); + #[cfg(feature = "app-weather")] + out.push(&makepad_weather::WEATHER_MODULE); } diff --git a/crates/shell/src/sandbox/tests.rs b/crates/shell/src/sandbox/tests.rs index d8269de5..6d29728c 100644 --- a/crates/shell/src/sandbox/tests.rs +++ b/crates/shell/src/sandbox/tests.rs @@ -50,6 +50,7 @@ fn jail_only(root: &Path, hub_port: u16) -> Policy { tools_json: "[]", generic_tools: &[], grants: &[], + system_tools: &[], calls_per_turn: None, calls_per_day: None, }; diff --git a/crates/shell/src/shell/launcher.rs b/crates/shell/src/shell/launcher.rs index 2dda1580..c02c4c9f 100644 --- a/crates/shell/src/shell/launcher.rs +++ b/crates/shell/src/shell/launcher.rs @@ -115,7 +115,7 @@ pub fn apps_uncached() -> Vec { // Launchable: a package this checkout can run, or a module this // build links (the only kind the web build has). .filter(|app| crate::apps::is_launchable(app)) - .filter(|app| !is_hidden(&app.id, &hides)) + .filter(|app| crate::apps::listed(&app.id) && !is_hidden(&app.id, &hides)) .map(|app| MenuItem { id: format!("apps.{}", app.id), label: app.label.clone(), diff --git a/crates/shell/src/system_chat/grants.rs b/crates/shell/src/system_chat/grants.rs index 6a556f29..afdcda03 100644 --- a/crates/shell/src/system_chat/grants.rs +++ b/crates/shell/src/system_chat/grants.rs @@ -171,7 +171,21 @@ pub fn command_execution() -> bool { /// registers on its session (`peer/tools/register` without `peer`, /// UPCR-2026-035) and the relay checks every call against. pub fn host_tools() -> std::collections::BTreeSet { - host_tools_given(command_execution(), terminal_target()) + let mut tools = host_tools_given(command_execution(), terminal_target()); + tools.extend(native_system_tools()); + tools +} + +/// The native apps' own read tools the system agent may call +/// (`native-apps.json` `agent.system_tools`), for the apps this build +/// links: Calculator's `eval`, Notes' `search` and `read`, … A call reaches +/// the app's open instance; a closed app answers that it is not running. +pub fn native_system_tools() -> std::collections::BTreeSet { + crate::native_apps::APPS + .iter() + .filter(|app| crate::apps::is_linked(app.id)) + .flat_map(|app| app.system_tools.iter().map(|tool| tool.to_string())) + .collect() } /// Whether `terminal.run` has a target: the Terminal runs as its own diff --git a/crates/shell/src/system_chat/tests.rs b/crates/shell/src/system_chat/tests.rs index 171190cc..0c0ab211 100644 --- a/crates/shell/src/system_chat/tests.rs +++ b/crates/shell/src/system_chat/tests.rs @@ -1046,3 +1046,20 @@ fn an_open_routed_question_is_never_dropped() { super::trim_routed(&mut all_open, super::ROUTED_KEPT); assert_eq!(all_open.len(), 40, "open questions are never dropped"); } + +/// The system agent gets the native apps' own read tools that their +/// manifest entries name (`agent.system_tools`), for the apps this build +/// links, and never the Terminal's: its command stays behind Setup's switch. +#[test] +fn the_system_agent_gets_the_linked_native_apps_read_tools_never_the_terminals() { + let tools = grants::native_system_tools(); + for app in crate::native_apps::APPS { + for tool in app.system_tools { + assert_eq!(tools.contains(*tool), crate::apps::is_linked(app.id), "{tool}"); + } + } + assert!(!tools.contains("terminal.run") && !tools.contains("terminal.read_screen")); + let calculator = crate::native_apps::find("calculator").unwrap(); + assert_eq!(calculator.system_tools, ["calculator.eval"]); + assert_eq!(crate::native_apps::find("notes").unwrap().system_tools, ["notes.search", "notes.read"]); +} diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index 2c94a03f..bd9430bd 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -49,7 +49,7 @@ octosense-shell = { workspace = true } # every standard build. `mobile-apps` is the set phone builds link, also # usable on the desktop; AppCard is not among them (`--features app-appcard`). # BEGIN native-apps: features (generated by tools/native_apps.py from native-apps.json; do not edit) -default = ["octos-core", "app-rinx", "app-terminal", "app-hub"] +default = ["octos-core", "app-rinx", "app-terminal", "app-hub", "app-calculator", "app-clock", "app-notes", "app-reminders", "app-weather"] mobile-apps = ["octosense-shell/mobile-apps"] app-rinx = ["octosense-shell/app-rinx"] app-reference = ["octosense-shell/app-reference"] @@ -57,6 +57,11 @@ app-sheets = ["octosense-shell/app-sheets"] app-terminal = ["octosense-shell/app-terminal"] app-appcard = ["octosense-shell/app-appcard"] app-hub = ["octosense-shell/app-hub"] +app-calculator = ["octosense-shell/app-calculator"] +app-clock = ["octosense-shell/app-clock"] +app-notes = ["octosense-shell/app-notes"] +app-reminders = ["octosense-shell/app-reminders"] +app-weather = ["octosense-shell/app-weather"] # END native-apps: features # The phone shell alone, on the desktop (Android sets it itself). mobile-only = ["octosense-shell/mobile-only"] diff --git a/desktop/README.md b/desktop/README.md index d91764f6..1cd3cddd 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -250,7 +250,7 @@ Open **App Hub**, choose the app, **Get**, scroll to **Install**, then **Open**: ### Developer programs and the catalog -`config/apps.json` lists Reference and Makepad's own apps (Browser, Files, Terminal, Sheets, Notes, Calendar, Director under the id `studio`, and more). Terminal is also linked (`app-terminal`, on by default); its `config/apps.json` row is the process form it opens in on macOS and Windows, and the linked module is the in-process form. The Image, PDF and AI helpers also appear in the launcher unless their ids (`image`, `pdf`, `aichat`) are listed in `wm/launcher.hides` under the state directory. +`config/apps.json` lists Reference and the Makepad apps OctoSense picked (Browser, Files, Task, Terminal, Sheets, Clock, Weather, Finance, Notes, Reminders, Calculator, Route, and the Image and PDF viewers). Calculator, Clock, Notes, Reminders and Weather are native apps ([`../native-apps.json`](../native-apps.json)): linked by default and opened in-process, with their read tools offered to the system agent while they are open. Terminal is also linked (`app-terminal`, on by default); its `config/apps.json` row is the process form it opens in on macOS and Windows, and the linked module is the in-process form. The `aichat` row is the assistant pane's own process (F10), which the pane starts; no list shows it. A launcher row whose id is listed in `wm/launcher.hides` under the state directory is hidden. Catalog lookup: `--apps ` if given, else `~/.octosense/apps.json` if it exists, else `config/apps.json`. A catalog is a JSON array; each entry picks one launch target: diff --git a/desktop/README.zh-CN.md b/desktop/README.zh-CN.md index 6d8ca42d..da9b2de4 100644 --- a/desktop/README.zh-CN.md +++ b/desktop/README.zh-CN.md @@ -250,7 +250,7 @@ OCTOSENSE_HUB= OCTOSENSE_HUB_ANCHOR= \ ### 开发者程序与目录 -`config/apps.json` 列出 Reference 和 Makepad 自带的应用(Browser、Files、Terminal、Sheets、Notes、Calendar、id 为 `studio` 的 Director 等)。Terminal 同时以链接方式提供(`app-terminal`,默认开启);它在 `config/apps.json` 中的条目是它在 macOS 和 Windows 上使用的独立进程形式,链接的模块是进程内形式。Image、PDF 和 AI 辅助应用也会出现在 launcher 中,除非它们的 id(`image`、`pdf`、`aichat`)写在状态目录下的 `wm/launcher.hides` 中。 +`config/apps.json` 列出 Reference 和 OctoSense 挑选的 Makepad 应用(Browser、Files、Task、Terminal、Sheets、Clock、Weather、Finance、Notes、Reminders、Calculator、Route,以及 Image 和 PDF 查看器)。Calculator、Clock、Notes、Reminders 和 Weather 是原生应用([`../native-apps.json`](../native-apps.json)):默认链接、在进程内打开,打开期间它们的只读工具提供给系统 Agent。Terminal 同时以链接方式提供(`app-terminal`,默认开启);它在 `config/apps.json` 中的条目是它在 macOS 和 Windows 上使用的独立进程形式,链接的模块是进程内形式。`aichat` 条目是助手面板自己的进程(F10),由面板启动;任何列表都不显示它。id 写在状态目录下 `wm/launcher.hides` 中的 launcher 条目会被隐藏。 目录查找顺序:给了 `--apps ` 就用它;否则若存在 `~/.octosense/apps.json` 就用它;否则用 `config/apps.json`。目录是一个 JSON 数组,每个条目选择一种启动目标: diff --git a/desktop/docs/code-walkthrough.md b/desktop/docs/code-walkthrough.md index c8e32893..94eca0ae 100644 --- a/desktop/docs/code-walkthrough.md +++ b/desktop/docs/code-walkthrough.md @@ -38,7 +38,7 @@ cargo run --locked -p octosense-reference # Reference linked into the desktop and opened at startup. MAKEPAD_WM_TEST_APP=reference cargo run --locked -p octosense --features app-reference -- --module reference -# Standard desktop: App Hub, Rinx, Terminal and kernel integration. +# Standard desktop: App Hub, Rinx, Terminal, the native Makepad apps and kernel integration. cargo run --locked --release -p octosense ``` diff --git a/desktop/docs/code-walkthrough.zh-CN.md b/desktop/docs/code-walkthrough.zh-CN.md index 4e7e6377..43d834c6 100644 --- a/desktop/docs/code-walkthrough.zh-CN.md +++ b/desktop/docs/code-walkthrough.zh-CN.md @@ -35,7 +35,7 @@ cargo run --locked -p octosense-reference # 把 Reference 链接进桌面,并在启动后打开。 MAKEPAD_WM_TEST_APP=reference cargo run --locked -p octosense --features app-reference -- --module reference -# 标准桌面:App Hub、Rinx、Terminal 和内核集成。 +# 标准桌面:App Hub、Rinx、Terminal、原生 Makepad 应用和内核集成。 cargo run --locked --release -p octosense ``` diff --git a/native-apps.json b/native-apps.json index ec1cf6f4..e1267fb6 100644 --- a/native-apps.json +++ b/native-apps.json @@ -111,6 +111,123 @@ "sandbox": { "network": "any", "processes": false }, "storage": { "accounts": false, "agent_workspace": "none", "external": [] }, "agent": { "octos": [], "tools": null } + }, + { + "id": "calculator", + "crate": "makepad-calculator", + "source": { "git": "https://github.com/OctoSense-org/makepad.git", "rev": "c155f61d0e1600d2ec474209374444a38a09a470", "local": ".sources/makepad/apps/calculator" }, + "module": "makepad_calculator::CALCULATOR_MODULE", + "bin": "calculator", + "bin_features": ["standalone"], + "default_features": false, + "crate_features": [], + "implies": [], + "hosting": { "macos": "module", "windows": "module", "linux": "module", "android": "module", "ios": "module", "ohos": "module" }, + "shells": { "desktop": "default", "phone": "off" }, + "native_mobile": "feature", + "sandbox": { "network": "none", "processes": false }, + "storage": { "accounts": false, "agent_workspace": "none", "external": [] }, + "agent": { + "octos": ["octos.session.open", "octos.session.history", "octos.turn.start", "octos.turn.interrupt"], + "tools": [ + {"name": "calculator.eval", "description": "Evaluate an expression. Uses the on-screen angle mode. Does not change the calculation, memory, or history.", "input_schema": {"type": "object", "properties": {"expression": {"type": "string", "maxLength": 1024}}, "required": ["expression"], "additionalProperties": false}, "risk": "read", "shareable": true} + ], + "system_tools": ["calculator.eval"] + } + }, + { + "id": "clock", + "crate": "makepad-clock", + "source": { "git": "https://github.com/OctoSense-org/makepad.git", "rev": "c155f61d0e1600d2ec474209374444a38a09a470", "local": ".sources/makepad/apps/clock" }, + "module": "makepad_clock::CLOCK_MODULE", + "bin": "clock", + "bin_features": ["standalone"], + "default_features": false, + "crate_features": [], + "implies": [], + "hosting": { "macos": "module", "windows": "module", "linux": "module", "android": "module", "ios": "module", "ohos": "module" }, + "shells": { "desktop": "default", "phone": "off" }, + "native_mobile": "feature", + "sandbox": { "network": "none", "processes": false }, + "storage": { "accounts": false, "agent_workspace": "none", "external": [] }, + "agent": { + "octos": ["octos.session.open", "octos.session.history", "octos.turn.start", "octos.turn.interrupt"], + "tools": [ + {"name": "clock.now", "description": "The current local time and date, the next alarm, and any running timer or stopwatch.", "input_schema": {"type": "object", "properties": {}}, "risk": "read", "shareable": true} + ], + "system_tools": ["clock.now"] + } + }, + { + "id": "notes", + "crate": "makepad-notes", + "source": { "git": "https://github.com/OctoSense-org/makepad.git", "rev": "c155f61d0e1600d2ec474209374444a38a09a470", "local": ".sources/makepad/apps/notes" }, + "module": "makepad_notes::NOTES_MODULE", + "bin": "notes", + "bin_features": ["standalone"], + "default_features": false, + "crate_features": [], + "implies": [], + "hosting": { "macos": "module", "windows": "module", "linux": "module", "android": "module", "ios": "module", "ohos": "module" }, + "shells": { "desktop": "default", "phone": "off" }, + "native_mobile": "feature", + "sandbox": { "network": "none", "processes": false }, + "storage": { "accounts": false, "agent_workspace": "none", "external": [] }, + "agent": { + "octos": ["octos.session.open", "octos.session.history", "octos.turn.start", "octos.turn.interrupt"], + "tools": [ + {"name": "notes.search", "description": "Find active notes whose title or body contains the query. Answers id, title, preview, folder, edited_ms and pinned.", "input_schema": {"type": "object", "properties": {"query": {"type": "string", "description": "text to find"}}, "required": ["query"]}, "risk": "read", "shareable": true}, + {"name": "notes.read", "description": "Read one active note by id: folder, title, complete source, timestamps and pinned state.", "input_schema": {"type": "object", "properties": {"id": {"type": "string", "description": "note id such as n000001"}}, "required": ["id"]}, "risk": "read", "shareable": true} + ], + "system_tools": ["notes.search", "notes.read"] + } + }, + { + "id": "reminders", + "crate": "makepad-reminders", + "source": { "git": "https://github.com/OctoSense-org/makepad.git", "rev": "c155f61d0e1600d2ec474209374444a38a09a470", "local": ".sources/makepad/apps/reminders" }, + "module": "makepad_reminders::REMINDERS_MODULE", + "bin": "reminders", + "bin_features": ["standalone"], + "default_features": false, + "crate_features": [], + "implies": [], + "hosting": { "macos": "module", "windows": "module", "linux": "module", "android": "module", "ios": "module", "ohos": "module" }, + "shells": { "desktop": "default", "phone": "off" }, + "native_mobile": "feature", + "sandbox": { "network": "none", "processes": false }, + "storage": { "accounts": false, "agent_workspace": "none", "external": [] }, + "agent": { + "octos": ["octos.session.open", "octos.session.history", "octos.turn.start", "octos.turn.interrupt"], + "tools": [ + {"name": "reminders.due", "description": "Incomplete dated reminders due before today plus N days, including overdue items and today.", "input_schema": {"type": "object", "properties": {"days": {"type": "integer", "minimum": 1, "maximum": 31}}, "required": ["days"], "additionalProperties": false}, "risk": "read", "shareable": true}, + {"name": "reminders.list", "description": "Reminders in a smart list or personal list, using the same projection as the UI.", "input_schema": {"type": "object", "properties": {"name": {"type": "string", "minLength": 1, "maxLength": 64}}, "required": ["name"], "additionalProperties": false}, "risk": "read", "shareable": true} + ], + "system_tools": ["reminders.due", "reminders.list"] + } + }, + { + "id": "weather", + "crate": "makepad-weather", + "source": { "git": "https://github.com/OctoSense-org/makepad.git", "rev": "c155f61d0e1600d2ec474209374444a38a09a470", "local": ".sources/makepad/apps/weather" }, + "module": "makepad_weather::WEATHER_MODULE", + "bin": "weather", + "bin_features": ["standalone"], + "default_features": false, + "crate_features": [], + "implies": [], + "hosting": { "macos": "module", "windows": "module", "linux": "module", "android": "module", "ios": "module", "ohos": "module" }, + "shells": { "desktop": "default", "phone": "off" }, + "native_mobile": "feature", + "sandbox": { "network": "any", "processes": false }, + "storage": { "accounts": false, "agent_workspace": "none", "external": [] }, + "agent": { + "octos": ["octos.session.open", "octos.session.history", "octos.turn.start", "octos.turn.interrupt"], + "tools": [ + {"name": "weather.current", "description": "Current conditions and the 5-day forecast for the city selected on screen.", "input_schema": {"type": "object", "properties": {}}, "risk": "read", "shareable": true} + ], + "system_tools": ["weather.current"] + } } ] } diff --git a/tools/native_apps.py b/tools/native_apps.py index 4513e34b..b7df346d 100644 --- a/tools/native_apps.py +++ b/tools/native_apps.py @@ -276,7 +276,7 @@ def tool_policy_problems(policy): return problems -AGENT_KEYS = {"octos", "tools", "generic_tools", "grants", "tool_policy", "budget"} +AGENT_KEYS = {"octos", "tools", "generic_tools", "grants", "tool_policy", "budget", "system_tools"} # The fields a `tools.json` entry may carry (octos `ToolDecl`, UPCR-2026-035); # `app` is the shell's to set. DECL_FIELDS = {"name", "description", "input_schema", "output_schema", "risk", "background", "outward", "confirm", "shareable"} @@ -291,7 +291,9 @@ def agent_problems(ident, agent): services), `tools` (null, or its own `tools.json` entries, named `.`), `generic_tools` (the exact octos kernel tools its agent gets; never octos's shell), `grants` (other apps' shareable tools, - `{"app", "tool"}`), `budget` (`calls_per_turn`, `calls_per_day`).""" + `{"app", "tool"}`), `budget` (`calls_per_turn`, `calls_per_day`), + `system_tools` (its own tools the system agent may call while the app + runs: each one of its `tools`, a shareable read tool, by full name).""" problems = [] unknown = sorted(set(agent) - AGENT_KEYS) if unknown: @@ -349,6 +351,19 @@ def agent_problems(ident, agent): problems.append(f"agent.generic_tools: {g!r} is not an octos tool name") if len(set(map(str, generic))) != len(generic): problems.append("agent.generic_tools names a tool twice") + system = agent.get("system_tools", []) + declared = {t.get("name"): t for t in (tools or []) if isinstance(t, dict)} + if not isinstance(system, list) or not all(isinstance(t, str) for t in system): + problems.append("agent.system_tools must be a list of its own tool names") + system = [] + for name in system: + tool = declared.get(name) + if tool is None: + problems.append(f"agent.system_tools: {name} is not one of {ident}'s agent.tools") + elif tool.get("risk") != "read" or tool.get("shareable") is not True: + problems.append(f"agent.system_tools: {name} must be a shareable read tool (the system agent gets read tools only)") + if len(set(system)) != len(system): + problems.append("agent.system_tools names a tool twice") budget = agent.get("budget") if budget is not None: if not isinstance(budget, dict) or not set(budget) <= {"calls_per_turn", "calls_per_day"}: @@ -626,6 +641,9 @@ def render_rust(apps): " /// `agent.grants`: other apps' shareable tools its agent may call,", " /// as (owning app, tool).", " pub grants: &'static [(&'static str, &'static str)],", + " /// `agent.system_tools`: its own read tools the system agent may", + " /// call while the app runs (full names).", + " pub system_tools: &'static [&'static str],", " /// `agent.budget`: its agent's tool calls per turn and per day", " /// (`None`: the shell's defaults).", " pub calls_per_turn: Option,", @@ -670,6 +688,8 @@ def render_rust(apps): out.append(f" generic_tools: &[{generic}],") grants = ", ".join(f"({s(g['app'])}, {s(g['tool'])})" for g in agent.get("grants", [])) out.append(f" grants: &[{grants}],") + system = ", ".join(s(x) for x in agent.get("system_tools", [])) + out.append(f" system_tools: &[{system}],") budget = agent.get("budget") or {} for key in ("calls_per_turn", "calls_per_day"): value = f"Some({budget[key]})" if key in budget else "None" diff --git a/tools/test_native_apps.py b/tools/test_native_apps.py index 336c7cf8..36956b02 100644 --- a/tools/test_native_apps.py +++ b/tools/test_native_apps.py @@ -31,7 +31,8 @@ def test_every_generated_place_matches_the_manifest(self): def test_the_manifest_declares_todays_native_apps(self): apps = native_apps.load(ROOT) - self.assertEqual([app["id"] for app in apps], ["rinx", "reference", "sheets", "terminal", "appcard", "apphub"]) + self.assertEqual([app["id"] for app in apps], ["rinx", "reference", "sheets", "terminal", "appcard", "apphub", + "calculator", "clock", "notes", "reminders", "weather"]) hosting = {app["id"]: app["hosting"] for app in apps} # Terminal is the only process app for now (ADR 0004 §2). self.assertEqual(hosting["terminal"]["macos"], "process") @@ -208,6 +209,24 @@ def test_grants_name_another_apps_shareable_tool(self): rinx["grants"] = [{"app": "nowhere", "tool": "nowhere.x"}] self.assertRefused(r"no native app nowhere") + def test_the_system_agent_gets_only_an_apps_own_shareable_read_tools(self): + """`agent.system_tools`: what the system agent may call of an app's + own tools is named per app, and only its shareable read tools + qualify; the Terminal's command is never one of them.""" + terminal = self.app("terminal")["agent"] + terminal["system_tools"] = ["terminal.read_screen"] + apps = native_apps.validate(self.data) + self.assertIn('system_tools: &["terminal.read_screen"],', native_apps.render_rust(apps)) + terminal["system_tools"] = ["terminal.run"] + self.assertRefused(r"agent\.system_tools: terminal\.run must be a shareable read tool") + terminal["system_tools"] = ["terminal.nope"] + self.assertRefused(r"agent\.system_tools: terminal\.nope is not one of terminal's agent\.tools") + terminal["system_tools"] = ["terminal.read_screen", "terminal.read_screen"] + self.assertRefused(r"agent\.system_tools names a tool twice") + self.app("terminal")["agent"]["tools"][1]["shareable"] = False + terminal["system_tools"] = ["terminal.read_screen"] + self.assertRefused(r"terminal\.read_screen must be a shareable read tool") + def test_the_agent_block_is_generated(self): self.app("rinx")["agent"]["grants"] = [{"app": "terminal", "tool": "terminal.read_screen"}] self.app("rinx")["agent"]["budget"] = {"calls_per_day": 99} @@ -254,25 +273,26 @@ def test_a_hand_edit_inside_a_block_is_drift(self): def test_a_new_app_reaches_every_place(self): extra = copy.deepcopy(self.app("sheets")) - extra.update({"id": "notes", "crate": "makepad-notes", "module": "makepad_notes::NOTES_MODULE", "bin": "notes", + extra.update({"id": "image", "crate": "makepad-image", "module": "makepad_image::IMAGE_MODULE", "bin": "image", "shells": {"desktop": "default", "phone": "off"}, "native_mobile": "feature"}) - extra["source"]["local"] = ".sources/makepad/apps/notes" + extra["source"]["local"] = ".sources/makepad/apps/image" self.data["apps"].append(extra) self.save() self.assertEqual(self.run_main("--no-lock"), 0) root = (self.root / "Cargo.toml").read_text() rev = extra["source"]["rev"] # the Makepad pin, whatever it is today - self.assertIn(f'makepad-notes = {{ git = "https://github.com/OctoSense-org/makepad.git", rev = "{rev}", default-features = false }}', root) - self.assertIn('makepad-notes = { path = ".sources/makepad/apps/notes" }', root) + self.assertIn(f'makepad-image = {{ git = "https://github.com/OctoSense-org/makepad.git", rev = "{rev}", default-features = false }}', root) + self.assertIn('makepad-image = { path = ".sources/makepad/apps/image" }', root) shell = (self.root / "crates/shell/Cargo.toml").read_text() - self.assertIn('makepad-notes = { workspace = true, optional = true }', shell) - self.assertIn('app-notes = ["dep:makepad-notes"]', shell) + self.assertIn('makepad-image = { workspace = true, optional = true }', shell) + self.assertIn('app-image = ["dep:makepad-image"]', shell) desktop = (self.root / "desktop/Cargo.toml").read_text() - self.assertIn('default = ["octos-core", "app-rinx", "app-terminal", "app-hub", "app-notes"]', desktop) - self.assertIn('app-notes = ["octosense-shell/app-notes"]', desktop) - self.assertNotIn("app-notes", (self.root / "phone/Cargo.toml").read_text()) + default = next(line for line in desktop.splitlines() if line.startswith("default = ")) + self.assertIn('"app-image"', default, "a desktop default app is in the package's default features") + self.assertIn('app-image = ["octosense-shell/app-image"]', desktop) + self.assertNotIn("app-image", (self.root / "phone/Cargo.toml").read_text()) rust = (self.root / native_apps.RUST_FILE).read_text() - self.assertIn(' #[cfg(feature = "app-notes")]\n out.push(&makepad_notes::NOTES_MODULE);', rust) + self.assertIn(' #[cfg(feature = "app-image")]\n out.push(&makepad_image::IMAGE_MODULE);', rust) def test_native_mobile_links_without_the_feature(self): rust = native_apps.render_rust(native_apps.validate(self.data)) From b3127ae069d6cce0838bb9535cda76a59df7aa8f Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:39:07 -0700 Subject: [PATCH 3/3] Task: a native app that runs only as its own process A native app may now have no module (`"module": null`). It is built from its binary into crates/process-apps and never linked into a shell. On a target without processes its hosting is `none`. tools/native_apps.py checks the shape: a bin is required, the shells are off, and `none` is for an app without a module only. The shell's hosting and the legacy- home adoption skip such an app. Task (the process monitor) is the first, sandboxed like every native process app. A process-only app is never linked, so the system agent's grants now take the native apps that run here: linked, or able to start as their own process (apps::process_form). A closed app answers that it is not running, as a closed module app does. Browser follows on its own. Through process-apps, makepad-browser enables makepad-widgets' `cef` feature, which unifies into every `cargo test -p makepad-widgets` in this workspace (the phone workflow runs two). makepad-cef's build script then fetches a Chromium distribution in CI, so Browser needs a build in Makepad's own workspace. AGENTS.md and the READMEs name Task. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- Cargo.lock | 10 ++++++ Cargo.toml | 2 ++ crates/process-apps/Cargo.toml | 1 + crates/shell/src/apps.rs | 4 ++- crates/shell/src/clients.rs | 6 ++-- crates/shell/src/native_apps.rs | 31 +++++++++++++++- crates/shell/src/system_chat/grants.rs | 16 ++++++--- crates/shell/src/system_chat/tests.rs | 17 ++++++--- desktop/README.md | 4 +-- desktop/README.zh-CN.md | 4 +-- native-apps.json | 16 +++++++++ tools/native_apps.py | 39 +++++++++++++++----- tools/test_native_apps.py | 50 ++++++++++++++++++++++++-- 14 files changed, 171 insertions(+), 31 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3ade0aa7..c8548263 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,7 +25,7 @@ | A system app's agent: its tools and the cards they publish | `apps//bundle/tools.json` and the manifest's `agent` block; the tools run on the app's host service | its host service's tests (below); then in a shell, with the system agent asking the app's agent | | A host service (`mail`, `calendar`, `news`, `llm`, `model`) | `apps/mail/host-service/`, `apps/calendar/host-service/`, `apps/news/host-service/`, `apps/ai-providers/` (`llm` and `model.complete`) | `cargo test --locked -p octosense-mail-service -p octosense-calendar-service -p octosense-news-service -p octosense-llm-service` | | AppCard (opt-in) | `apps/appcard/` | [apps/appcard/AGENTS.md](apps/appcard/AGENTS.md), `apps.yml` | -| A native app (App Hub, Rinx, Terminal, Sheets, Reference, AppCard, and the Makepad apps made native: Calculator, Clock, Notes, Reminders, Weather): its crate, pin, features, hosting per target, sandbox, storage, its tools and which of them the system agent may call (`agent.system_tools`) | `native-apps.json` only; `python3 tools/native_apps.py` writes the marked blocks in the `Cargo.toml`s, `crates/shell/src/native_apps.rs` and `Cargo.lock` | `python3 tools/native_apps.py --check`, `python3 -m unittest discover -s tools -p 'test_*.py'` | +| A native app (App Hub, Rinx, Terminal, Sheets, Reference, AppCard, and the Makepad apps made native: Calculator, Clock, Notes, Reminders, Weather, and Task, which runs only as its own process): its crate, pin, features, hosting per target, sandbox, storage, its tools and which of them the system agent may call (`agent.system_tools`) | `native-apps.json` only; `python3 tools/native_apps.py` writes the marked blocks in the `Cargo.toml`s, `crates/shell/src/native_apps.rs` and `Cargo.lock` | `python3 tools/native_apps.py --check`, `python3 -m unittest discover -s tools -p 'test_*.py'` | | An external pin (Makepad, OctoScript, App Hub, octos, Rinx) | root `Cargo.toml` `[workspace.dependencies]` (a native app's in `native-apps.json`), `native-runtime.lock.json`, `runtime-patches.lock.json` | `python3 tools/setup.py --update`, then `--check --cargo` and `python3 tools/native_apps.py --check` | | A decision | `docs/adr/` (next free number) | — | diff --git a/Cargo.lock b/Cargo.lock index b23236cf..36653bbf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5710,6 +5710,15 @@ dependencies = [ "makepad-live-id", ] +[[package]] +name = "makepad-task" +version = "0.1.0" +dependencies = [ + "makepad-widgets", + "makepad-wm-api", + "makepad-wm-theme", +] + [[package]] name = "makepad-terminal" version = "0.1.0" @@ -7736,6 +7745,7 @@ dependencies = [ "makepad-notes", "makepad-reminders", "makepad-sheets", + "makepad-task", "makepad-terminal", "makepad-weather", "octosense-reference", diff --git a/Cargo.toml b/Cargo.toml index 4f9035d6..0bd5cb95 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -75,6 +75,7 @@ makepad-clock = { git = "https://github.com/OctoSense-org/makepad.git", rev = "c makepad-notes = { git = "https://github.com/OctoSense-org/makepad.git", rev = "c155f61d0e1600d2ec474209374444a38a09a470", default-features = false } makepad-reminders = { git = "https://github.com/OctoSense-org/makepad.git", rev = "c155f61d0e1600d2ec474209374444a38a09a470", default-features = false } makepad-weather = { git = "https://github.com/OctoSense-org/makepad.git", rev = "c155f61d0e1600d2ec474209374444a38a09a470", default-features = false } +makepad-task = { git = "https://github.com/OctoSense-org/makepad.git", rev = "c155f61d0e1600d2ec474209374444a38a09a470" } # END native-apps: pins # --- Makepad (OctoSense-org/makepad), the revision Octoscript-Makepad's # runtime.json pins (native-runtime.lock.json); resolved to .sources/makepad. @@ -246,6 +247,7 @@ makepad-clock = { path = ".sources/makepad/apps/clock" } makepad-notes = { path = ".sources/makepad/apps/notes" } makepad-reminders = { path = ".sources/makepad/apps/reminders" } makepad-weather = { path = ".sources/makepad/apps/weather" } +makepad-task = { path = ".sources/makepad/apps/task" } # END native-apps: patch https://github.com/OctoSense-org/makepad.git # AppCard's inline-renderer kits name makepad-widgets by the ZhangHanDong URL. diff --git a/crates/process-apps/Cargo.toml b/crates/process-apps/Cargo.toml index 97e30173..53eb1099 100644 --- a/crates/process-apps/Cargo.toml +++ b/crates/process-apps/Cargo.toml @@ -25,4 +25,5 @@ makepad-clock = { workspace = true, features = ["standalone"] } makepad-notes = { workspace = true, features = ["standalone"] } makepad-reminders = { workspace = true, features = ["standalone"] } makepad-weather = { workspace = true, features = ["standalone"] } +makepad-task = { workspace = true } # END native-apps: process deps diff --git a/crates/shell/src/apps.rs b/crates/shell/src/apps.rs index 5d92c0f8..21957c97 100644 --- a/crates/shell/src/apps.rs +++ b/crates/shell/src/apps.rs @@ -515,7 +515,9 @@ pub fn is_launchable(app: &crate::clients::AppDef) -> bool { pub fn manifest_default(declared: crate::native_apps::Hosting, process_form: impl FnOnce() -> bool, vulkan_wayland: bool) -> Hosting { use crate::native_apps::Hosting as Declared; let wants_process = match declared { - Declared::Module => false, + // `None` is a process-only app's where it cannot run; nothing links + // such an app, so it never reaches here as a module. + Declared::Module | Declared::None => false, Declared::Process => true, Declared::ProcessIfVulkan => vulkan_wayland, }; diff --git a/crates/shell/src/clients.rs b/crates/shell/src/clients.rs index 5ce72bce..f0b6139a 100644 --- a/crates/shell/src/clients.rs +++ b/crates/shell/src/clients.rs @@ -1538,7 +1538,7 @@ pub fn adopt_legacy_homes_later(layout: &crate::app_storage::Layout) { } else { Hosting::Module }; - if matches!(hosting, Hosting::Module) { + if matches!(hosting, Hosting::Module | Hosting::None) { continue; } if let Ok(paths) = layout.app(app.id) { @@ -1777,7 +1777,7 @@ mod tests { #[test] fn makepad_launches_build_outside_cargos_checkout() { let apps = crate::octosense::catalog::parse_catalog( - br#"[{"id":"browser","label":"Browser","source":"makepad","package":"makepad-browser","bin":"browser"}]"#, + br#"[{"id":"example","label":"Example","source":"makepad","package":"makepad-example","bin":"example"}]"#, Path::new("/catalog"), Some(Path::new("/cargo/checkouts/makepad-d00a/ad8f372")), ) @@ -1787,7 +1787,7 @@ mod tests { assert!(build.args.windows(2).any(|p| p == ["--manifest-path", "/cargo/checkouts/makepad-d00a/ad8f372/Cargo.toml"])); let at = build.args.iter().position(|arg| arg == "--target-dir").expect("builds are redirected out of the cargo cache"); assert!(!build.args[at + 1].starts_with("/cargo/checkouts"), "{}", build.args[at + 1]); - assert_eq!(plan.program, build.target_dir.join("release").join(exe_name("browser"))); + assert_eq!(plan.program, build.target_dir.join("release").join(exe_name("example"))); assert_eq!(build.dir, Path::new("/cargo/checkouts/makepad-d00a/ad8f372"), "cargo runs in its checkout"); } diff --git a/crates/shell/src/native_apps.rs b/crates/shell/src/native_apps.rs index 2eb906a7..43fd4aeb 100644 --- a/crates/shell/src/native_apps.rs +++ b/crates/shell/src/native_apps.rs @@ -15,8 +15,11 @@ pub enum Hosting { /// Its own process (`bin`), connected to the shell's hub. Process, /// Its own process on a Vulkan build in a Wayland session; in the - /// shell's process otherwise. + /// shell's process otherwise (a process-only app: not there). ProcessIfVulkan, + /// Not on this target: a process-only app (no module) where + /// there are no processes. + None, } /// Who shows the person a tool's live confirmation (ADR 0004 §8). @@ -372,6 +375,31 @@ pub const APPS: &[NativeApp] = &[ calls_per_turn: None, calls_per_day: None, }, + NativeApp { + id: "task", + feature: "app-task", + bin: Some("task"), + macos: Hosting::Process, + windows: Hosting::Process, + linux: Hosting::ProcessIfVulkan, + android: Hosting::None, + ios: Hosting::None, + ohos: Hosting::None, + wasm: Hosting::None, + octos: &[], + tools: &[], + network: Network::None, + processes: true, + accounts: false, + external: &[], + storage: r#"{"accounts": false, "agent_workspace": "none", "external": []}"#, + tools_json: r##"[]"##, + generic_tools: &[], + grants: &[], + system_tools: &[], + calls_per_turn: None, + calls_per_day: None, + }, ]; impl NativeApp { @@ -420,6 +448,7 @@ pub fn package_of(id: &str) -> Option<&'static str> { "notes" => Some("makepad-notes"), "reminders" => Some("makepad-reminders"), "weather" => Some("makepad-weather"), + "task" => Some("makepad-task"), _ => None, } } diff --git a/crates/shell/src/system_chat/grants.rs b/crates/shell/src/system_chat/grants.rs index afdcda03..dd7e93b4 100644 --- a/crates/shell/src/system_chat/grants.rs +++ b/crates/shell/src/system_chat/grants.rs @@ -177,13 +177,21 @@ pub fn host_tools() -> std::collections::BTreeSet { } /// The native apps' own read tools the system agent may call -/// (`native-apps.json` `agent.system_tools`), for the apps this build -/// links: Calculator's `eval`, Notes' `search` and `read`, … A call reaches -/// the app's open instance; a closed app answers that it is not running. +/// (`native-apps.json` `agent.system_tools`), for the apps that run here: +/// linked into this build (Calculator's `eval`, Notes' `search` and `read`, +/// …) or started as their own process (a process-only app such as Task). +/// A call reaches the app's open instance; a closed app answers that it is +/// not running. pub fn native_system_tools() -> std::collections::BTreeSet { + let processes = crate::host::processes_available(); + native_system_tools_given(|id| crate::apps::is_linked(id) || (processes && crate::apps::process_form(id))) +} + +/// [`native_system_tools`] for the apps `runs_here` says this device has. +pub fn native_system_tools_given(runs_here: impl Fn(&str) -> bool) -> std::collections::BTreeSet { crate::native_apps::APPS .iter() - .filter(|app| crate::apps::is_linked(app.id)) + .filter(|app| runs_here(app.id)) .flat_map(|app| app.system_tools.iter().map(|tool| tool.to_string())) .collect() } diff --git a/crates/shell/src/system_chat/tests.rs b/crates/shell/src/system_chat/tests.rs index 0c0ab211..4e1a178f 100644 --- a/crates/shell/src/system_chat/tests.rs +++ b/crates/shell/src/system_chat/tests.rs @@ -1048,17 +1048,24 @@ fn an_open_routed_question_is_never_dropped() { } /// The system agent gets the native apps' own read tools that their -/// manifest entries name (`agent.system_tools`), for the apps this build -/// links, and never the Terminal's: its command stays behind Setup's switch. +/// manifest entries name (`agent.system_tools`), for the apps that run here +/// (linked, or started as their own process), and never the Terminal's: its +/// command stays behind Setup's switch. #[test] -fn the_system_agent_gets_the_linked_native_apps_read_tools_never_the_terminals() { +fn the_system_agent_gets_the_read_tools_of_the_native_apps_that_run_here_never_the_terminals() { let tools = grants::native_system_tools(); for app in crate::native_apps::APPS { + let here = grants::native_system_tools_given(|id| id == app.id); + let elsewhere = grants::native_system_tools_given(|id| id != app.id); for tool in app.system_tools { - assert_eq!(tools.contains(*tool), crate::apps::is_linked(app.id), "{tool}"); + assert!(here.contains(*tool) && !elsewhere.contains(*tool), "{tool}"); + if crate::apps::is_linked(app.id) { + assert!(tools.contains(*tool), "{tool}"); + } } } - assert!(!tools.contains("terminal.run") && !tools.contains("terminal.read_screen")); + let every = grants::native_system_tools_given(|_| true); + assert!(!every.contains("terminal.run") && !every.contains("terminal.read_screen")); let calculator = crate::native_apps::find("calculator").unwrap(); assert_eq!(calculator.system_tools, ["calculator.eval"]); assert_eq!(crate::native_apps::find("notes").unwrap().system_tools, ["notes.search", "notes.read"]); diff --git a/desktop/README.md b/desktop/README.md index 1cd3cddd..9d9fae87 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -99,7 +99,7 @@ The native apps' features (`app-hub`, `app-rinx`, `app-reference`, `app-sheets`, | `app-aichat` | off | Links Makepad's AI chat as a module, without its model engine. | | `mobile-apps` | off | `app-rinx` + `app-reference` + `app-sheets` + `app-hub` + `octos-core`: the set phone builds link, for testing on desktop. Not AppCard. | -A linked native app is hosted as its `hosting` in `native-apps.json` says for the platform: App Hub, Rinx, AppCard, Reference and Sheets in-process everywhere, the Terminal as a process on macOS and Windows (and on Linux with a Vulkan build in a Wayland session). `--module ` (or a `: Module` line in `wm/apps.splash` under the state directory) opens one in-process instead: +A linked native app is hosted as its `hosting` in `native-apps.json` says for the platform: App Hub, Rinx, AppCard, Reference and Sheets in-process everywhere, the Terminal as a process on macOS and Windows (and on Linux with a Vulkan build in a Wayland session), and Task, which has no module, only as a process, and not at all where there are no processes. `--module ` (or a `: Module` line in `wm/apps.splash` under the state directory) opens one in-process instead: ```sh cargo run --release -p octosense -- --module terminal @@ -250,7 +250,7 @@ Open **App Hub**, choose the app, **Get**, scroll to **Install**, then **Open**: ### Developer programs and the catalog -`config/apps.json` lists Reference and the Makepad apps OctoSense picked (Browser, Files, Task, Terminal, Sheets, Clock, Weather, Finance, Notes, Reminders, Calculator, Route, and the Image and PDF viewers). Calculator, Clock, Notes, Reminders and Weather are native apps ([`../native-apps.json`](../native-apps.json)): linked by default and opened in-process, with their read tools offered to the system agent while they are open. Terminal is also linked (`app-terminal`, on by default); its `config/apps.json` row is the process form it opens in on macOS and Windows, and the linked module is the in-process form. The `aichat` row is the assistant pane's own process (F10), which the pane starts; no list shows it. A launcher row whose id is listed in `wm/launcher.hides` under the state directory is hidden. +`config/apps.json` lists Reference and the Makepad apps OctoSense picked (Browser, Files, Task, Terminal, Sheets, Clock, Weather, Finance, Notes, Reminders, Calculator, Route, and the Image and PDF viewers). Calculator, Clock, Notes, Reminders and Weather are native apps ([`../native-apps.json`](../native-apps.json)): linked by default and opened in-process, with their read tools offered to the system agent while they are open. Task is a native app that runs only as its own process (`"module": null`), sandboxed. Terminal is also linked (`app-terminal`, on by default); its `config/apps.json` row is the process form it opens in on macOS and Windows, and the linked module is the in-process form. The `aichat` row is the assistant pane's own process (F10), which the pane starts; no list shows it. A launcher row whose id is listed in `wm/launcher.hides` under the state directory is hidden. Catalog lookup: `--apps ` if given, else `~/.octosense/apps.json` if it exists, else `config/apps.json`. A catalog is a JSON array; each entry picks one launch target: diff --git a/desktop/README.zh-CN.md b/desktop/README.zh-CN.md index da9b2de4..558102f6 100644 --- a/desktop/README.zh-CN.md +++ b/desktop/README.zh-CN.md @@ -99,7 +99,7 @@ cargo run --release -p octosense | `app-aichat` | 关 | 以模块形式链接 Makepad 的 AI chat,不含其模型引擎。 | | `mobile-apps` | 关 | `app-rinx` + `app-reference` + `app-sheets` + `app-hub` + `octos-core`:手机构建所链接的集合,用于在桌面上测试。不含 AppCard。 | -已链接的原生应用按照 `native-apps.json` 中该平台的 `hosting` 托管:App Hub、Rinx、AppCard、Reference 和 Sheets 在所有平台上都在进程内运行,Terminal 在 macOS 和 Windows 上(以及 Vulkan 构建且处于 Wayland 会话的 Linux 上)作为独立进程运行。用 `--module `(或在状态目录下的 `wm/apps.splash` 中写一行 `: Module`)可改为在进程内打开: +已链接的原生应用按照 `native-apps.json` 中该平台的 `hosting` 托管:App Hub、Rinx、AppCard、Reference 和 Sheets 在所有平台上都在进程内运行,Terminal 在 macOS 和 Windows 上(以及 Vulkan 构建且处于 Wayland 会话的 Linux 上)作为独立进程运行,没有模块的 Task 只作为独立进程运行,在没有进程的平台上不提供。用 `--module `(或在状态目录下的 `wm/apps.splash` 中写一行 `: Module`)可改为在进程内打开: ```sh cargo run --release -p octosense -- --module terminal @@ -250,7 +250,7 @@ OCTOSENSE_HUB= OCTOSENSE_HUB_ANCHOR= \ ### 开发者程序与目录 -`config/apps.json` 列出 Reference 和 OctoSense 挑选的 Makepad 应用(Browser、Files、Task、Terminal、Sheets、Clock、Weather、Finance、Notes、Reminders、Calculator、Route,以及 Image 和 PDF 查看器)。Calculator、Clock、Notes、Reminders 和 Weather 是原生应用([`../native-apps.json`](../native-apps.json)):默认链接、在进程内打开,打开期间它们的只读工具提供给系统 Agent。Terminal 同时以链接方式提供(`app-terminal`,默认开启);它在 `config/apps.json` 中的条目是它在 macOS 和 Windows 上使用的独立进程形式,链接的模块是进程内形式。`aichat` 条目是助手面板自己的进程(F10),由面板启动;任何列表都不显示它。id 写在状态目录下 `wm/launcher.hides` 中的 launcher 条目会被隐藏。 +`config/apps.json` 列出 Reference 和 OctoSense 挑选的 Makepad 应用(Browser、Files、Task、Terminal、Sheets、Clock、Weather、Finance、Notes、Reminders、Calculator、Route,以及 Image 和 PDF 查看器)。Calculator、Clock、Notes、Reminders 和 Weather 是原生应用([`../native-apps.json`](../native-apps.json)):默认链接、在进程内打开,打开期间它们的只读工具提供给系统 Agent。Task 是只作为独立进程运行的原生应用(`"module": null`),在沙箱中运行。Terminal 同时以链接方式提供(`app-terminal`,默认开启);它在 `config/apps.json` 中的条目是它在 macOS 和 Windows 上使用的独立进程形式,链接的模块是进程内形式。`aichat` 条目是助手面板自己的进程(F10),由面板启动;任何列表都不显示它。id 写在状态目录下 `wm/launcher.hides` 中的 launcher 条目会被隐藏。 目录查找顺序:给了 `--apps ` 就用它;否则若存在 `~/.octosense/apps.json` 就用它;否则用 `config/apps.json`。目录是一个 JSON 数组,每个条目选择一种启动目标: diff --git a/native-apps.json b/native-apps.json index e1267fb6..07ac1c96 100644 --- a/native-apps.json +++ b/native-apps.json @@ -228,6 +228,22 @@ ], "system_tools": ["weather.current"] } + }, + { + "id": "task", + "crate": "makepad-task", + "source": { "git": "https://github.com/OctoSense-org/makepad.git", "rev": "c155f61d0e1600d2ec474209374444a38a09a470", "local": ".sources/makepad/apps/task" }, + "module": null, + "bin": "task", + "default_features": true, + "crate_features": [], + "implies": [], + "hosting": { "macos": "process", "windows": "process", "linux": "process-if-vulkan", "android": "none", "ios": "none", "ohos": "none" }, + "shells": { "desktop": "off", "phone": "off" }, + "native_mobile": "feature", + "sandbox": { "network": "none", "processes": true }, + "storage": { "accounts": false, "agent_workspace": "none", "external": [] }, + "agent": { "octos": [], "tools": null } } ] } diff --git a/tools/native_apps.py b/tools/native_apps.py index b7df346d..21f606f2 100644 --- a/tools/native_apps.py +++ b/tools/native_apps.py @@ -63,7 +63,7 @@ TARGETS = ("macos", "windows", "linux", "android", "ios", "ohos") # Targets without child processes (`host::processes_available()` is false). MODULE_ONLY = ("android", "ios", "ohos", "wasm") -HOSTINGS = ("module", "process", "process-if-vulkan") +HOSTINGS = ("module", "process", "process-if-vulkan", "none") # How a package links an app: in its default features, in its `mobile-apps` # set, only when asked for, or not at all. SHELL_LINKS = ("default", "mobile-apps", "opt-in", "off") @@ -148,8 +148,17 @@ def validate(data): problems.append(f"{where}: source.tag must be a release tag vX.Y.Z or vX.Y.Z-rc.N") if isinstance(source, dict) and ident in TAGGED_RELEASES and "tag" not in source: problems.append(f"{where}: is taken only as a tagged release (hagency-org/Rinx#37): pin source.tag, not a raw rev") - if not modules_of(app) or not all(re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*(::[A-Za-z_][A-Za-z0-9_]*)+", m) for m in modules_of(app)): - problems.append(f"{where}: module must be a Rust path (or a list of them)") + process_only = app["module"] is None + if not process_only and (not modules_of(app) or not all(re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*(::[A-Za-z_][A-Za-z0-9_]*)+", m) for m in modules_of(app))): + problems.append(f"{where}: module must be a Rust path (or a list of them), or null for a process-only app") + if process_only: + # No module: the app runs only as its own desktop process (ADR + # 0004 §2: a browser whose engine owns its process, a task + # manager). Nothing links it, so no shell names it. + if not app["bin"]: + problems.append(f"{where}: a process-only app (module null) needs a bin") + if app["shells"] != {"desktop": "off", "phone": "off"} if isinstance(app["shells"], dict) else True: + problems.append(f"{where}: a process-only app is linked by no shell: shells must be off") if app["bin"] is not None and not (isinstance(app["bin"], str) and app["bin"]): problems.append(f"{where}: bin must be a binary name or null") bin_features = app.get("bin_features", []) @@ -174,12 +183,16 @@ def validate(data): problems.append(f"{where}: hosting.{target}: unknown target") elif value not in HOSTINGS: problems.append(f"{where}: hosting.{target}: {value!r} is not one of {', '.join(HOSTINGS)}") - elif value != "module" and target in MODULE_ONLY: - problems.append(f"{where}: hosting.{target}: {target} has no processes; only 'module'") + elif value == "none" and app["module"] is not None: + problems.append(f"{where}: hosting.{target}: 'none' is for a process-only app (module null)") + elif value == "module" and app["module"] is None: + problems.append(f"{where}: hosting.{target}: a process-only app (module null) has no module") + elif value not in ("module", "none") and target in MODULE_ONLY: + problems.append(f"{where}: hosting.{target}: {target} has no processes; only 'module' (or 'none' for a process-only app)") elif value == "process" and target == "linux": problems.append(f"{where}: hosting.linux: 'process' would run without Vulkan+Wayland; " "use 'process-if-vulkan' (ADR 0004 §2)") - elif value != "module" and not app["bin"]: + elif value in ("process", "process-if-vulkan") and not app["bin"]: problems.append(f"{where}: hosting.{target}: '{value}' needs a bin") shells = app["shells"] if not isinstance(shells, dict) or set(shells) != set(PACKAGES): @@ -412,6 +425,7 @@ def feature_of(app): def modules_of(app): + """The app's linked modules; none for a process-only app.""" module = app["module"] if isinstance(module, str): return [module] @@ -487,6 +501,8 @@ def dep(app, optional): fields.append("optional = true") return f"{app['crate']} = {{ {', '.join(fields)} }}" + # A process-only app is no dependency of the shell's: nothing links it. + apps = [app for app in apps if modules_of(app)] mobile_apps = [feature_of(app) for app in apps if in_mobile_apps(app)] + BASE_DEFAULT features = [f"mobile-apps = {toml_list(mobile_apps)}"] features += [f"{feature_of(app)} = {toml_list(['dep:' + app['crate']] + app['implies'])}" for app in apps] @@ -549,7 +565,7 @@ def rust_raw(text): def rust_hosting(value): return {"module": "Hosting::Module", "process": "Hosting::Process", - "process-if-vulkan": "Hosting::ProcessIfVulkan"}[value] + "process-if-vulkan": "Hosting::ProcessIfVulkan", "none": "Hosting::None"}[value] def render_rust(apps): @@ -571,8 +587,11 @@ def render_rust(apps): " /// Its own process (`bin`), connected to the shell's hub.", " Process,", " /// Its own process on a Vulkan build in a Wayland session; in the", - " /// shell's process otherwise.", + " /// shell's process otherwise (a process-only app: not there).", " ProcessIfVulkan,", + " /// Not on this target: a process-only app (no module) where", + " /// there are no processes.", + " None,", "}", "", "/// Who shows the person a tool's live confirmation (ADR 0004 §8).", @@ -654,7 +673,7 @@ def render_rust(apps): ] for app in apps: hosting = dict(app["hosting"]) - hosting.setdefault("wasm", "module") + hosting.setdefault("wasm", "module" if modules_of(app) else "none") bin_value = f"Some({s(app['bin'])})" if app["bin"] else "None" octos = ", ".join(s(x) for x in app["agent"]["octos"]) out.append(" NativeApp {") @@ -751,6 +770,8 @@ def render_rust(apps): feature = f"feature = {s(feature_of(app))}" cfg = f"any({feature}, native_mobile)" if app["native_mobile"] == "always" else feature modules = modules_of(app) + if not modules: + continue out.append(f" #[cfg({cfg})]") if len(modules) == 1: out.append(f" out.push(&{modules[0]});") diff --git a/tools/test_native_apps.py b/tools/test_native_apps.py index 36956b02..08bf6923 100644 --- a/tools/test_native_apps.py +++ b/tools/test_native_apps.py @@ -32,15 +32,20 @@ def test_every_generated_place_matches_the_manifest(self): def test_the_manifest_declares_todays_native_apps(self): apps = native_apps.load(ROOT) self.assertEqual([app["id"] for app in apps], ["rinx", "reference", "sheets", "terminal", "appcard", "apphub", - "calculator", "clock", "notes", "reminders", "weather"]) + "calculator", "clock", "notes", "reminders", "weather", + "task"]) hosting = {app["id"]: app["hosting"] for app in apps} - # Terminal is the only process app for now (ADR 0004 §2). + # Terminal is the only app that runs both linked and as a process + # (ADR 0004 §2); Task has no module and runs only as one. + self.assertIsNone(next(app for app in apps if app["id"] == "task")["module"]) + self.assertEqual(hosting["task"]["macos"], "process") self.assertEqual(hosting["terminal"]["macos"], "process") self.assertEqual(hosting["terminal"]["windows"], "process") self.assertEqual(hosting["terminal"]["linux"], "process-if-vulkan") for ident in ("apphub", "rinx", "sheets", "reference", "appcard"): self.assertEqual(set(hosting[ident].values()), {"module"}, ident) - # Non-Vulkan Linux is in-process for everything. + # Non-Vulkan Linux is in-process for every app with a module (a + # process-only app is not there). for ident, h in hosting.items(): self.assertIn(h["linux"], ("module", "process-if-vulkan"), ident) @@ -294,6 +299,45 @@ def test_a_new_app_reaches_every_place(self): rust = (self.root / native_apps.RUST_FILE).read_text() self.assertIn(' #[cfg(feature = "app-image")]\n out.push(&makepad_image::IMAGE_MODULE);', rust) + def process_only(self): + """Task: the manifest's process-only app (`module: null`).""" + app = self.app("task") + self.assertIsNone(app["module"]) + return app + + def test_a_process_only_app_is_built_never_linked(self): + """`module: null` (ADR 0004 §2): the app runs only as its own + desktop process. The process-app crate builds it; no shell links + it, and it is not there where there are no processes.""" + self.process_only() + self.save() + self.assertEqual(self.run_main("--no-lock"), 0) + self.assertIn('makepad-task = { workspace = true }', (self.root / "crates/process-apps/Cargo.toml").read_text()) + self.assertNotIn("makepad-task", (self.root / "crates/shell/Cargo.toml").read_text()) + self.assertNotIn("app-task", (self.root / "desktop/Cargo.toml").read_text()) + rust = (self.root / native_apps.RUST_FILE).read_text() + self.assertNotIn('feature = "app-task"', rust, "nothing links it") + self.assertIn('bin: Some("task"),\n macos: Hosting::Process,', rust) + self.assertIn("android: Hosting::None,", rust) + self.assertIn("wasm: Hosting::None,", rust) + + def test_a_process_only_app_is_checked(self): + app = self.process_only() + native_apps.validate(self.data) + app["bin"] = None + self.assertRefused(r"task: a process-only app \(module null\) needs a bin") + app["bin"] = "task" + app["hosting"]["android"] = "module" + self.assertRefused(r"hosting\.android: a process-only app \(module null\) has no module") + app["hosting"]["android"] = "process" + self.assertRefused(r"hosting\.android: android has no processes") + app["hosting"]["android"] = "none" + app["shells"]["desktop"] = "default" + self.assertRefused(r"task: a process-only app is linked by no shell: shells must be off") + app["shells"]["desktop"] = "off" + self.app("terminal")["hosting"]["android"] = "none" + self.assertRefused(r"terminal: hosting\.android: 'none' is for a process-only app") + def test_native_mobile_links_without_the_feature(self): rust = native_apps.render_rust(native_apps.validate(self.data)) self.assertIn('#[cfg(any(feature = "app-reference", native_mobile))]', rust)