@notis_ai/cli 0.2.12 → 0.2.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +56 -3
  2. package/dist/scaffolds/notis-database/packages/sdk/src/config.ts +40 -2
  3. package/dist/scaffolds/notis-database/packages/sdk/src/documents.ts +21 -0
  4. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useCloudComputer.ts +97 -0
  5. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useDatabaseSubscription.ts +76 -0
  6. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useHandover.ts +75 -0
  7. package/dist/scaffolds/notis-database/packages/sdk/src/index.ts +17 -0
  8. package/dist/scaffolds/notis-database/packages/sdk/src/runtime.ts +132 -1
  9. package/dist/scaffolds/notis-journal/packages/sdk/src/config.ts +40 -2
  10. package/dist/scaffolds/notis-journal/packages/sdk/src/documents.ts +21 -0
  11. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useCloudComputer.ts +97 -0
  12. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useDatabaseSubscription.ts +76 -0
  13. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useHandover.ts +75 -0
  14. package/dist/scaffolds/notis-journal/packages/sdk/src/index.ts +17 -0
  15. package/dist/scaffolds/notis-journal/packages/sdk/src/runtime.ts +132 -1
  16. package/dist/scaffolds/notis-journal/src/mock-runtime.ts +2 -0
  17. package/dist/scaffolds/notis-notes/packages/sdk/src/config.ts +40 -2
  18. package/dist/scaffolds/notis-notes/packages/sdk/src/documents.ts +21 -0
  19. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useCloudComputer.ts +97 -0
  20. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useDatabaseSubscription.ts +76 -0
  21. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useHandover.ts +75 -0
  22. package/dist/scaffolds/notis-notes/packages/sdk/src/index.ts +17 -0
  23. package/dist/scaffolds/notis-notes/packages/sdk/src/runtime.ts +132 -1
  24. package/dist/scaffolds/notis-random/packages/sdk/src/config.ts +40 -2
  25. package/dist/scaffolds/notis-random/packages/sdk/src/documents.ts +21 -0
  26. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useCloudComputer.ts +97 -0
  27. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useDatabaseSubscription.ts +76 -0
  28. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useHandover.ts +75 -0
  29. package/dist/scaffolds/notis-random/packages/sdk/src/index.ts +17 -0
  30. package/dist/scaffolds/notis-random/packages/sdk/src/runtime.ts +132 -1
  31. package/package.json +1 -1
  32. package/skills/notis-apps/SKILL.md +11 -7
  33. package/skills/notis-apps/cli.md +8 -3
  34. package/skills/notis-cli/SKILL.md +2 -0
  35. package/src/cli.js +158 -0
  36. package/src/command-specs/apps.js +238 -50
  37. package/src/command-specs/handover.js +374 -0
  38. package/src/command-specs/index.js +3 -0
  39. package/src/command-specs/meta.js +53 -0
  40. package/src/command-specs/tools.js +6 -0
  41. package/src/runtime/app-dev-server.js +17 -8
  42. package/src/runtime/app-platform.js +218 -6
  43. package/src/runtime/auth-recovery.js +13 -3
  44. package/src/runtime/channel.js +133 -0
  45. package/src/runtime/delegated-context.js +68 -0
  46. package/src/runtime/git.js +233 -0
  47. package/src/runtime/oauth.js +36 -4
  48. package/src/runtime/profiles.js +17 -1
  49. package/src/runtime/transport.js +19 -2
  50. package/template/.harness/index.html.tmpl +116 -47
  51. package/template/packages/sdk/src/config.ts +52 -0
  52. package/template/packages/sdk/src/documents.ts +21 -0
  53. package/template/packages/sdk/src/hooks/useCloudComputer.ts +97 -0
  54. package/template/packages/sdk/src/hooks/useDatabaseSubscription.ts +76 -0
  55. package/template/packages/sdk/src/hooks/useHandover.ts +75 -0
  56. package/template/packages/sdk/src/index.ts +17 -0
  57. package/template/packages/sdk/src/runtime.ts +132 -1
  58. package/template/metadata/screenshot-1.png +0 -0
  59. package/template/metadata/screenshot-2.png +0 -0
  60. package/template/metadata/screenshot-3.png +0 -0
@@ -85,7 +85,12 @@ export interface NotisAppAuthor {
85
85
  export interface NotisAppSkillConfig {
86
86
  /** Stable source-owned key used by other app declarations. */
87
87
  key: string;
88
- /** Path to the skill entrypoint, relative to notis.config.ts. */
88
+ /**
89
+ * Path to the skill, relative to notis.config.ts. Either a Markdown file
90
+ * (`./skills/onboarding.md`) or a directory holding SKILL.md plus its
91
+ * supporting files (`./skills/onboarding/`), which are packaged on deploy
92
+ * and materialized next to SKILL.md in the sandbox.
93
+ */
89
94
  path: string;
90
95
  /** User-facing name used for the installed skill. */
91
96
  name: string;
@@ -106,7 +111,13 @@ export interface NotisAppScreenshotConfig {
106
111
  alt: string;
107
112
  /** Route slug captured by `notis apps screenshot`. */
108
113
  route?: string;
109
- /** Optional fixture scenario from metadata/screenshot-fixtures.json. */
114
+ /**
115
+ * Named scenario from metadata/screenshot-fixtures.json. Its `tools` and
116
+ * `requests` override the file-level ones key by key for this capture, and
117
+ * its `actions` run once the route has mounted -- so one route can be shown
118
+ * in several states (populated, empty, a panel opened) without the states
119
+ * leaking into each other.
120
+ */
110
121
  scenario?: string;
111
122
  /** Optional CSS selector captured as the truthful focal region for this Store image. */
112
123
  focus?: string;
@@ -142,6 +153,25 @@ export interface NotisAppCapabilities {
142
153
  * bound to the app's own databases.
143
154
  */
144
155
  workspaceDatabases?: 'read';
156
+
157
+ /**
158
+ * Read a few facts about the user's cloud computer: whether a sandbox exists
159
+ * and is running, and whether the GitHub CLI is signed in there.
160
+ *
161
+ * Without this an app has to infer them — the Workspaces app treated a
162
+ * configured repository as proof that `gh auth login` had happened, which
163
+ * cannot show an account name and cannot notice a revoked credential.
164
+ * `'read'` never creates, resumes or commands a sandbox. Read it with
165
+ * `useCloudComputer()`.
166
+ *
167
+ * `'shell'` additionally asks to command the cloud computer: it unlocks
168
+ * `LOCAL_NOTIS_RUN_SANDBOX_SHELL` and the sandbox file tools from this app's
169
+ * views (they are denied to every view otherwise), and implies the read
170
+ * facts. This is the same authority the user's own agent has on the sandbox,
171
+ * so the user is asked for it explicitly at install or in the Store grant
172
+ * step; declare it only when the app's core actions genuinely run there.
173
+ */
174
+ cloudComputer?: 'read' | 'shell';
145
175
  }
146
176
 
147
177
  export interface NotisAppConfig {
@@ -179,6 +209,14 @@ export interface NotisAppConfig {
179
209
  */
180
210
  capabilities?: NotisAppCapabilities;
181
211
  routes?: NotisRouteConfig[];
212
+ /**
213
+ * Final tool names this app can call at runtime, enforced server-side. Use
214
+ * names returned by shared discovery, including native `LOCAL_NOTIS_*`,
215
+ * connected-service names such as `GMAIL_SEND_EMAIL`,
216
+ * `LOCAL_POSTFORME_*`, and `LOCAL_MCP_<SERVER>_<TOOL>`. App code calls
217
+ * each declared name directly with `useTool`; metered calls use the shared
218
+ * credit-cap and usage-billing path.
219
+ */
182
220
  tools?: string[];
183
221
  /** Skills shipped from this app's source tree. */
184
222
  skills?: NotisAppSkillConfig[];
@@ -12,6 +12,7 @@ import type {
12
12
  DatabasePropertyType,
13
13
  DocumentContentType,
14
14
  DocumentRecord,
15
+ SecretPropertyValue,
15
16
  } from './runtime';
16
17
 
17
18
  // ---------------------------------------------------------------------------
@@ -45,6 +46,23 @@ export function extractRichText(value: unknown): string {
45
46
  .join('');
46
47
  }
47
48
 
49
+ /**
50
+ * Reads a `secret` property value. The platform only ever sends the pointer
51
+ * ({present, reference, status, metadata}), so this rebuilds it field by field
52
+ * rather than passing the payload through — an app can never surface secret
53
+ * material through this helper, whatever the server sent.
54
+ */
55
+ export function getSecretValue(value: unknown): SecretPropertyValue {
56
+ const record = asRecord(value);
57
+ const metadata = asRecord(record?.metadata);
58
+ return {
59
+ present: record?.present === true,
60
+ reference: optionalString(record?.reference),
61
+ status: optionalString(record?.status),
62
+ metadata,
63
+ };
64
+ }
65
+
48
66
  /** Extracts the ids of a normalized relation property value. */
49
67
  export function getRelationIds(value: unknown): string[] {
50
68
  if (!Array.isArray(value)) return [];
@@ -81,6 +99,9 @@ export function normalizePropertyValue(value: unknown): unknown {
81
99
  return items.map((item) => optionalString(asRecord(item)?.id) ?? item).filter(Boolean);
82
100
  }
83
101
  if (type === 'date') return optionalString(asRecord(record.date)?.start) ?? record.date ?? null;
102
+ // Before the `type in record` fallthrough: a secret value has no `secret`
103
+ // key, so passing it through would hand the caller the raw payload.
104
+ if (type === 'secret') return getSecretValue(record);
84
105
  if (type in record) return record[type];
85
106
  return value;
86
107
  }
@@ -0,0 +1,97 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useEffect, useRef, useState } from 'react';
4
+ import { useNotisRuntime } from '../provider';
5
+ import type { CloudComputerFacts } from '../runtime';
6
+
7
+ export interface UseCloudComputerResult {
8
+ /**
9
+ * The facts, or null while the first read is in flight. `facts.available`
10
+ * is false when this host cannot answer — render the app's own fallback.
11
+ */
12
+ facts: CloudComputerFacts | null;
13
+ loading: boolean;
14
+ error: Error | null;
15
+ /** Re-read the facts. The platform caches them for a few minutes. */
16
+ refresh: () => Promise<void>;
17
+ }
18
+
19
+ const UNAVAILABLE: CloudComputerFacts = {
20
+ available: false,
21
+ reason: 'unsupported_host',
22
+ sandbox: null,
23
+ cli_auth: {
24
+ gh: { authenticated: null, account: null, checked_at: null, reason: 'unsupported_host' },
25
+ },
26
+ };
27
+
28
+ /**
29
+ * Read-only facts about the user's cloud computer.
30
+ *
31
+ * ```tsx
32
+ * const { facts } = useCloudComputer();
33
+ * const gh = facts?.available ? facts.cli_auth.gh : null;
34
+ *
35
+ * return gh?.authenticated
36
+ * ? <p>Signed in as {gh.account}</p>
37
+ * : <GithubConnect />;
38
+ * ```
39
+ *
40
+ * Requires `capabilities.cloudComputer: 'read'` in `notis.config.ts` and the
41
+ * user's approval at install time. It answers with state the platform already
42
+ * holds: reading it never creates, resumes or commands a sandbox, and the GitHub
43
+ * probe only runs when the sandbox is already awake. `authenticated: null`
44
+ * therefore means *unknown*, not *signed out* — keep the app's own fallback for
45
+ * that case and for hosts that answer `{ available: false }` (the dev harness,
46
+ * the vite preview).
47
+ */
48
+ export function useCloudComputer(): UseCloudComputerResult {
49
+ const runtime = useNotisRuntime();
50
+ const [facts, setFacts] = useState<CloudComputerFacts | null>(null);
51
+ // True from the first committed render: the initial read is already queued
52
+ // in an effect, and `{ loading: false, facts: null }` would flash a
53
+ // consumer's fallback branch before the answer arrives.
54
+ const [loading, setLoading] = useState(true);
55
+ const [error, setError] = useState<Error | null>(null);
56
+ const mounted = useRef(true);
57
+
58
+ useEffect(() => {
59
+ mounted.current = true;
60
+ return () => {
61
+ mounted.current = false;
62
+ };
63
+ }, []);
64
+
65
+ const read = useCallback(async (options?: { refresh?: boolean }) => {
66
+ if (!runtime?.cloudComputerFacts) {
67
+ setFacts(UNAVAILABLE);
68
+ setLoading(false);
69
+ return;
70
+ }
71
+
72
+ setLoading(true);
73
+ setError(null);
74
+ try {
75
+ const next = await runtime.cloudComputerFacts(options);
76
+ if (mounted.current) setFacts(next);
77
+ } catch (err) {
78
+ const e = err instanceof Error ? err : new Error(String(err));
79
+ if (mounted.current) {
80
+ setError(e);
81
+ // A refused or failed read is the same product state as a host that
82
+ // cannot answer: the app shows its fallback instead of an error.
83
+ setFacts(UNAVAILABLE);
84
+ }
85
+ } finally {
86
+ if (mounted.current) setLoading(false);
87
+ }
88
+ }, [runtime]);
89
+
90
+ useEffect(() => {
91
+ void read();
92
+ }, [read]);
93
+
94
+ const refresh = useCallback(() => read({ refresh: true }), [read]);
95
+
96
+ return { facts, loading, error, refresh };
97
+ }
@@ -0,0 +1,76 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useRef, useState } from 'react';
4
+ import { useNotisRuntime } from '../provider';
5
+ import { useDocuments, type UseDocumentsOptions, type UseDocumentsResult } from './useDocuments';
6
+ import type { DocumentRecord } from '../runtime';
7
+
8
+ export interface UseDatabaseSubscriptionOptions extends UseDocumentsOptions {
9
+ /** Set to false to keep the query but skip the change feed. */
10
+ subscribe?: boolean;
11
+ }
12
+
13
+ export interface UseDatabaseSubscriptionResult extends UseDocumentsResult {
14
+ /** Alias of `documents`, for views that think in rows. */
15
+ rows: DocumentRecord[];
16
+ /** True while a live change feed is attached to this database. */
17
+ live: boolean;
18
+ }
19
+
20
+ /**
21
+ * Query a Notis database and keep it fresh without polling.
22
+ *
23
+ * ```tsx
24
+ * const { rows, live, refetch } = useDatabaseSubscription('workspaces');
25
+ * ```
26
+ *
27
+ * A change on the database wakes the hook, which then refetches through the
28
+ * usual `LOCAL_NOTIS_DATABASE_QUERY` path — the change feed is a signal only
29
+ * and never carries row data. Hosts without a change feed (the dev harness,
30
+ * the screenshot stub, the vite preview) still return rows; `live` is false
31
+ * there and the app should keep offering its manual refresh.
32
+ */
33
+ export function useDatabaseSubscription(
34
+ databaseSlug: string,
35
+ options: UseDatabaseSubscriptionOptions = {},
36
+ ): UseDatabaseSubscriptionResult {
37
+ const runtime = useNotisRuntime();
38
+ const { subscribe = true, ...documentOptions } = options;
39
+ const { documents, loading, error, refetch } = useDocuments(databaseSlug, documentOptions);
40
+ const [live, setLive] = useState(false);
41
+
42
+ const refetchRef = useRef(refetch);
43
+ useEffect(() => {
44
+ refetchRef.current = refetch;
45
+ }, [refetch]);
46
+
47
+ const enabled = options.enabled !== false && subscribe;
48
+
49
+ useEffect(() => {
50
+ if (!runtime?.subscribeDatabase || !enabled || !databaseSlug) {
51
+ setLive(false);
52
+ return;
53
+ }
54
+
55
+ let cancelled = false;
56
+ const unsubscribe = runtime.subscribeDatabase(
57
+ databaseSlug,
58
+ () => {
59
+ refetchRef.current();
60
+ },
61
+ {
62
+ onStatusChange: (isLive) => {
63
+ if (!cancelled) setLive(isLive);
64
+ },
65
+ },
66
+ );
67
+
68
+ return () => {
69
+ cancelled = true;
70
+ setLive(false);
71
+ unsubscribe?.();
72
+ };
73
+ }, [runtime, databaseSlug, enabled]);
74
+
75
+ return { documents, rows: documents, loading, error, refetch, live };
76
+ }
@@ -0,0 +1,75 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useState } from 'react';
4
+ import { useNotisRuntime } from '../provider';
5
+ import type { HandoverPayload, HandoverResult } from '../runtime';
6
+
7
+ export interface UseHandoverResult {
8
+ /** Hand the work over. Rejects when the host has no manager chat. */
9
+ handover: (payload: HandoverPayload) => Promise<HandoverResult>;
10
+ /** True while the manager chat is being prepared. */
11
+ pending: boolean;
12
+ error: Error | null;
13
+ /**
14
+ * False when the host cannot hand work over (dev harness, vite preview).
15
+ * Render the app's own fallback — a copyable prompt, say — when it is false.
16
+ */
17
+ available: boolean;
18
+ }
19
+
20
+ /**
21
+ * Hand a piece of work from app code to the Notis manager chat.
22
+ *
23
+ * An app displays work; the manager runs it. `handover` puts the message in
24
+ * the chat surface that already owns streaming progress, billing, cancellation
25
+ * and the transcript, and the app watches its own databases for the result.
26
+ *
27
+ * ```tsx
28
+ * const { handover, pending, available } = useHandover();
29
+ *
30
+ * return available ? (
31
+ * <Button
32
+ * disabled={pending}
33
+ * onClick={() => { void handover({ prompt: 'Create a workspace on notis to ...' }); }}
34
+ * >
35
+ * Send to Notis
36
+ * </Button>
37
+ * ) : (
38
+ * <CopyablePrompt prompt="Create a workspace on notis to ..." />
39
+ * );
40
+ * ```
41
+ *
42
+ * Pass `skill` to bind the work to a skill declared in `notis.config.ts`; the
43
+ * host rejects a key the app does not declare. `autoSend` is accepted for
44
+ * forward compatibility; today's hosts always return `drafted` and let the
45
+ * user press send.
46
+ */
47
+ export function useHandover(): UseHandoverResult {
48
+ const runtime = useNotisRuntime();
49
+ const [pending, setPending] = useState(false);
50
+ const [error, setError] = useState<Error | null>(null);
51
+
52
+ const handover = useCallback(
53
+ async (payload: HandoverPayload): Promise<HandoverResult> => {
54
+ if (!runtime?.handover) {
55
+ throw new Error('This Notis host cannot hand work to the manager chat.');
56
+ }
57
+
58
+ setPending(true);
59
+ setError(null);
60
+
61
+ try {
62
+ return await runtime.handover(payload);
63
+ } catch (err) {
64
+ const e = err instanceof Error ? err : new Error(String(err));
65
+ setError(e);
66
+ throw e;
67
+ } finally {
68
+ setPending(false);
69
+ }
70
+ },
71
+ [runtime],
72
+ );
73
+
74
+ return { handover, pending, error, available: Boolean(runtime?.handover) };
75
+ }
@@ -13,6 +13,11 @@ export { NotisProvider, useNotisRuntime } from './provider';
13
13
  export { useNotis } from './hooks/useNotis';
14
14
  export { useDocuments } from './hooks/useDocuments';
15
15
  export type { UseDocumentsOptions, UseDocumentsResult } from './hooks/useDocuments';
16
+ export { useDatabaseSubscription } from './hooks/useDatabaseSubscription';
17
+ export type {
18
+ UseDatabaseSubscriptionOptions,
19
+ UseDatabaseSubscriptionResult,
20
+ } from './hooks/useDatabaseSubscription';
16
21
  export { useDocument } from './hooks/useDocument';
17
22
  export type { UseDocumentOptions, UseDocumentResult } from './hooks/useDocument';
18
23
  export { useUpsertDocument } from './hooks/useUpsertDocument';
@@ -22,6 +27,10 @@ export type { UseDatabaseSchemaResult } from './hooks/useDatabaseSchema';
22
27
  export { useTool } from './hooks/useTool';
23
28
  export type { ToolCallState, UseToolResult } from './hooks/useTool';
24
29
  export { useTools } from './hooks/useTools';
30
+ export { useHandover } from './hooks/useHandover';
31
+ export type { UseHandoverResult } from './hooks/useHandover';
32
+ export { useCloudComputer } from './hooks/useCloudComputer';
33
+ export type { UseCloudComputerResult } from './hooks/useCloudComputer';
25
34
  export { useNotisNavigation } from './hooks/useNotisNavigation';
26
35
  export { useTopBarSearch } from './hooks/useTopBarSearch';
27
36
  export { useBackend } from './hooks/useBackend';
@@ -37,6 +46,7 @@ export {
37
46
  extractRichText,
38
47
  getDocumentPreview,
39
48
  getRelationIds,
49
+ getSecretValue,
40
50
  isPresentString,
41
51
  markdownToPlainText,
42
52
  normalizeDatabaseProperty,
@@ -64,6 +74,9 @@ export type { MultiSelectDragOverlayProps } from './components/MultiSelectDragOv
64
74
  // Types (re-exported for convenience)
65
75
  export type {
66
76
  AppDescriptor,
77
+ CloudComputerCliAuthFacts,
78
+ CloudComputerFacts,
79
+ CloudComputerSandboxFacts,
67
80
  CollectionItem,
68
81
  CollectionItemDetail,
69
82
  DatabaseDescriptor,
@@ -72,12 +85,16 @@ export type {
72
85
  DatabasePropertyType,
73
86
  DocumentContentType,
74
87
  DocumentRecord,
88
+ HandoverPayload,
89
+ HandoverResult,
75
90
  NotisDocumentEditorProps,
76
91
  NotisRuntime,
77
92
  NotisRuntimeContext,
78
93
  NotisRuntimeUI,
79
94
  QueryFilter,
80
95
  RouteDescriptor,
96
+ SecretPropertyValue,
97
+ SubscribeDatabaseOptions,
81
98
  ToolDescriptor,
82
99
  ToolInputSchema,
83
100
  } from './runtime';
@@ -25,7 +25,21 @@ export type DatabasePropertyType =
25
25
  | 'status'
26
26
  | 'relation'
27
27
  | 'formula'
28
- | 'files';
28
+ | 'files'
29
+ | 'secret';
30
+
31
+ /**
32
+ * The value of a `secret` property. The platform stores a pointer to a
33
+ * credential held elsewhere and never the credential itself, so there is
34
+ * deliberately nothing here to read the secret material from — only whether
35
+ * one is attached, which credential it is, and its lifecycle state.
36
+ */
37
+ export interface SecretPropertyValue {
38
+ present: boolean;
39
+ reference: string | null;
40
+ status: string | null;
41
+ metadata: Record<string, unknown> | null;
42
+ }
29
43
 
30
44
  export interface DatabasePropertyOption {
31
45
  id?: string | null;
@@ -188,6 +202,84 @@ export interface NotisRuntimeUI {
188
202
  // NotisRuntime
189
203
  // ---------------------------------------------------------------------------
190
204
 
205
+ /**
206
+ * Options for `NotisRuntime.subscribeDatabase`.
207
+ */
208
+ export interface SubscribeDatabaseOptions {
209
+ /**
210
+ * Called with `true` once a live change feed is attached, and with `false`
211
+ * when it drops or is torn down. Hosts without a change feed (dev harness,
212
+ * screenshot stub, vite preview) never call it, so `live` stays false there.
213
+ */
214
+ onStatusChange?: (live: boolean) => void;
215
+ }
216
+
217
+ /**
218
+ * Work an app hands to the Notis manager chat through `NotisRuntime.handover`.
219
+ */
220
+ export interface HandoverPayload {
221
+ /** The message the manager should act on. */
222
+ prompt: string;
223
+ /**
224
+ * Key of a skill declared in `notis.config.ts` -> `skills[].key`. The host
225
+ * rejects a key this app does not declare. Omit to hand over plain work.
226
+ */
227
+ skill?: string;
228
+ /**
229
+ * Accepted for forward compatibility. The portal never submits a composer on
230
+ * the user's behalf today, so every handover resolves `drafted`; `sent` is
231
+ * reserved for a host that can genuinely dispatch the run.
232
+ */
233
+ autoSend?: boolean;
234
+ }
235
+
236
+ export interface HandoverResult {
237
+ /** `drafted` when the user still has to press send, `sent` when it went straight through. */
238
+ status: 'drafted' | 'sent';
239
+ }
240
+
241
+ /**
242
+ * The user's cloud computer, as far as an app may see it.
243
+ *
244
+ * `exists` is false when the user has never had a sandbox provisioned. A
245
+ * `status` of anything other than `'running'` means the VM is asleep; reading
246
+ * these facts never wakes it.
247
+ */
248
+ export interface CloudComputerSandboxFacts {
249
+ exists: boolean;
250
+ status: string | null;
251
+ provider: string | null;
252
+ created_at: string | null;
253
+ updated_at: string | null;
254
+ }
255
+
256
+ /**
257
+ * Whether a CLI inside the cloud computer is signed in.
258
+ *
259
+ * `authenticated: null` means *unknown*, never *signed out*: the sandbox was
260
+ * not running, or the probe could not answer. `reason` says which
261
+ * (`'sandbox_not_running'`, `'no_sandbox'`, `'sandbox_status_unknown'`,
262
+ * `'probe_failed'`, `'not_signed_in'`).
263
+ */
264
+ export interface CloudComputerCliAuthFacts {
265
+ authenticated: boolean | null;
266
+ account: string | null;
267
+ checked_at: string | null;
268
+ reason: string | null;
269
+ }
270
+
271
+ export interface CloudComputerFacts {
272
+ /**
273
+ * False when this host cannot answer at all — no cloud computer on the user's
274
+ * plan, or the platform could not resolve the facts. Render whatever the app
275
+ * did before rather than an error.
276
+ */
277
+ available: boolean;
278
+ reason?: string | null;
279
+ sandbox: CloudComputerSandboxFacts | null;
280
+ cli_auth: { gh: CloudComputerCliAuthFacts };
281
+ }
282
+
191
283
  export interface NotisRuntime {
192
284
  app: AppDescriptor;
193
285
  route: RouteDescriptor;
@@ -195,6 +287,45 @@ export interface NotisRuntime {
195
287
  context: NotisRuntimeContext;
196
288
  ui?: NotisRuntimeUI;
197
289
 
290
+ /**
291
+ * Subscribe to changes on an app-owned database. Returns an unsubscribe.
292
+ *
293
+ * The change notification is only a signal — it carries no rows. Consumers
294
+ * react by refetching through the normal tool path, so app scoping,
295
+ * permissions and billing are unchanged. Use the `useDatabaseSubscription`
296
+ * hook rather than calling this directly.
297
+ */
298
+ subscribeDatabase?(
299
+ slug: string,
300
+ onChange: () => void,
301
+ options?: SubscribeDatabaseOptions,
302
+ ): () => void;
303
+
304
+ /**
305
+ * Hand a piece of work to the Notis manager chat. The app cannot run an
306
+ * agent itself: it describes the job, and the manager surface owns progress,
307
+ * billing, cancellation and the transcript. Results come back to the app
308
+ * through its own databases (see `useDatabaseSubscription`).
309
+ *
310
+ * Use the `useHandover` hook rather than calling this directly. Hosts
311
+ * without a manager chat (the dev harness, the vite preview) leave it
312
+ * undefined, so keep whatever fallback the app already offers.
313
+ */
314
+ handover?(payload: HandoverPayload): Promise<HandoverResult>;
315
+
316
+ /**
317
+ * Read-only facts about the user's cloud computer. Requires
318
+ * `capabilities.cloudComputer: 'read'` in `notis.config.ts` plus the user's
319
+ * approval; resolving it never creates, resumes or commands a sandbox.
320
+ *
321
+ * Use the `useCloudComputer` hook rather than calling this directly. Hosts
322
+ * without a cloud computer (the dev harness, the vite preview) answer
323
+ * `{ available: false }`, so keep whatever fallback the app already has.
324
+ * `{ refresh: true }` bypasses the host's short answer cache — the hook's
325
+ * refresh() sends it so a just-completed sign-in becomes visible.
326
+ */
327
+ cloudComputerFacts?(options?: { refresh?: boolean }): Promise<CloudComputerFacts>;
328
+
198
329
  navigate?: (payload: { kind: string; [key: string]: unknown }) => void;
199
330
 
200
331
  registerTopBarSearch?: (
@@ -181,6 +181,8 @@ export function installMockRuntime(): NotisRuntime {
181
181
  registerTopBarSearch: () => {},
182
182
  setTopBarSearchValue: () => {},
183
183
  setTopBarSearchLoading: () => {},
184
+ // No change feed in the mock: `useDatabaseSubscription` reports live=false.
185
+ subscribeDatabase: () => () => {},
184
186
  async listTools() {
185
187
  return [];
186
188
  },
@@ -85,7 +85,12 @@ export interface NotisAppAuthor {
85
85
  export interface NotisAppSkillConfig {
86
86
  /** Stable source-owned key used by other app declarations. */
87
87
  key: string;
88
- /** Path to the skill entrypoint, relative to notis.config.ts. */
88
+ /**
89
+ * Path to the skill, relative to notis.config.ts. Either a Markdown file
90
+ * (`./skills/onboarding.md`) or a directory holding SKILL.md plus its
91
+ * supporting files (`./skills/onboarding/`), which are packaged on deploy
92
+ * and materialized next to SKILL.md in the sandbox.
93
+ */
89
94
  path: string;
90
95
  /** User-facing name used for the installed skill. */
91
96
  name: string;
@@ -106,7 +111,13 @@ export interface NotisAppScreenshotConfig {
106
111
  alt: string;
107
112
  /** Route slug captured by `notis apps screenshot`. */
108
113
  route?: string;
109
- /** Optional fixture scenario from metadata/screenshot-fixtures.json. */
114
+ /**
115
+ * Named scenario from metadata/screenshot-fixtures.json. Its `tools` and
116
+ * `requests` override the file-level ones key by key for this capture, and
117
+ * its `actions` run once the route has mounted -- so one route can be shown
118
+ * in several states (populated, empty, a panel opened) without the states
119
+ * leaking into each other.
120
+ */
110
121
  scenario?: string;
111
122
  /** Optional CSS selector captured as the truthful focal region for this Store image. */
112
123
  focus?: string;
@@ -142,6 +153,25 @@ export interface NotisAppCapabilities {
142
153
  * bound to the app's own databases.
143
154
  */
144
155
  workspaceDatabases?: 'read';
156
+
157
+ /**
158
+ * Read a few facts about the user's cloud computer: whether a sandbox exists
159
+ * and is running, and whether the GitHub CLI is signed in there.
160
+ *
161
+ * Without this an app has to infer them — the Workspaces app treated a
162
+ * configured repository as proof that `gh auth login` had happened, which
163
+ * cannot show an account name and cannot notice a revoked credential.
164
+ * `'read'` never creates, resumes or commands a sandbox. Read it with
165
+ * `useCloudComputer()`.
166
+ *
167
+ * `'shell'` additionally asks to command the cloud computer: it unlocks
168
+ * `LOCAL_NOTIS_RUN_SANDBOX_SHELL` and the sandbox file tools from this app's
169
+ * views (they are denied to every view otherwise), and implies the read
170
+ * facts. This is the same authority the user's own agent has on the sandbox,
171
+ * so the user is asked for it explicitly at install or in the Store grant
172
+ * step; declare it only when the app's core actions genuinely run there.
173
+ */
174
+ cloudComputer?: 'read' | 'shell';
145
175
  }
146
176
 
147
177
  export interface NotisAppConfig {
@@ -179,6 +209,14 @@ export interface NotisAppConfig {
179
209
  */
180
210
  capabilities?: NotisAppCapabilities;
181
211
  routes?: NotisRouteConfig[];
212
+ /**
213
+ * Final tool names this app can call at runtime, enforced server-side. Use
214
+ * names returned by shared discovery, including native `LOCAL_NOTIS_*`,
215
+ * connected-service names such as `GMAIL_SEND_EMAIL`,
216
+ * `LOCAL_POSTFORME_*`, and `LOCAL_MCP_<SERVER>_<TOOL>`. App code calls
217
+ * each declared name directly with `useTool`; metered calls use the shared
218
+ * credit-cap and usage-billing path.
219
+ */
182
220
  tools?: string[];
183
221
  /** Skills shipped from this app's source tree. */
184
222
  skills?: NotisAppSkillConfig[];
@@ -12,6 +12,7 @@ import type {
12
12
  DatabasePropertyType,
13
13
  DocumentContentType,
14
14
  DocumentRecord,
15
+ SecretPropertyValue,
15
16
  } from './runtime';
16
17
 
17
18
  // ---------------------------------------------------------------------------
@@ -45,6 +46,23 @@ export function extractRichText(value: unknown): string {
45
46
  .join('');
46
47
  }
47
48
 
49
+ /**
50
+ * Reads a `secret` property value. The platform only ever sends the pointer
51
+ * ({present, reference, status, metadata}), so this rebuilds it field by field
52
+ * rather than passing the payload through — an app can never surface secret
53
+ * material through this helper, whatever the server sent.
54
+ */
55
+ export function getSecretValue(value: unknown): SecretPropertyValue {
56
+ const record = asRecord(value);
57
+ const metadata = asRecord(record?.metadata);
58
+ return {
59
+ present: record?.present === true,
60
+ reference: optionalString(record?.reference),
61
+ status: optionalString(record?.status),
62
+ metadata,
63
+ };
64
+ }
65
+
48
66
  /** Extracts the ids of a normalized relation property value. */
49
67
  export function getRelationIds(value: unknown): string[] {
50
68
  if (!Array.isArray(value)) return [];
@@ -81,6 +99,9 @@ export function normalizePropertyValue(value: unknown): unknown {
81
99
  return items.map((item) => optionalString(asRecord(item)?.id) ?? item).filter(Boolean);
82
100
  }
83
101
  if (type === 'date') return optionalString(asRecord(record.date)?.start) ?? record.date ?? null;
102
+ // Before the `type in record` fallthrough: a secret value has no `secret`
103
+ // key, so passing it through would hand the caller the raw payload.
104
+ if (type === 'secret') return getSecretValue(record);
84
105
  if (type in record) return record[type];
85
106
  return value;
86
107
  }