@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
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Local git inspection for `notis handover`.
3
+ *
4
+ * The CLI has no other reason to know about git, so this stays deliberately
5
+ * small: read where we are, push the branch, and report what the remote is.
6
+ * Everything else about the repository is the cloud workspace's problem.
7
+ *
8
+ * Every call is `spawnSync` on the real `git` binary rather than a library.
9
+ * The user's credentials, hooks, SSH agent and includeIf config are what make a
10
+ * push succeed on their machine, and only their own git honors all of them.
11
+ */
12
+
13
+ import { spawnSync } from 'node:child_process';
14
+ import { lstatSync, readFileSync } from 'node:fs';
15
+ import { basename, relative, resolve, sep } from 'node:path';
16
+ import { CliError, EXIT_CODES } from './errors.js';
17
+
18
+ const GIT_TIMEOUT_MS = 120_000;
19
+ const MAX_SECRET_SCAN_BYTES = 1_000_000;
20
+ const SAFE_ENV_TEMPLATES = /^\.env\.(?:example|sample|template)$/i;
21
+ const SENSITIVE_PATH = /^(?:\.env(?:\..+)?|\.envrc|\.git-credentials|\.npmrc|\.pypirc|\.netrc|credentials(?:\..+)?|secrets?(?:\..+)?|id_(?:rsa|dsa|ecdsa|ed25519)(?:\.pub)?|.*\.(?:pem|key|p12|pfx|jks))$/i;
22
+ const SENSITIVE_CONTENT = [
23
+ /-----BEGIN (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----/,
24
+ /\b[a-z][a-z0-9+.-]*:\/\/[^/\s:@]+:[^/\s@]+@/i,
25
+ /\bAKIA[0-9A-Z]{16}\b/,
26
+ /\bgh[pousr]_[A-Za-z0-9]{36,}\b/,
27
+ /\bsk-[A-Za-z0-9_-]{20,}\b/,
28
+ ];
29
+
30
+ export function runGit(
31
+ args,
32
+ { cwd = process.cwd(), timeoutMs = GIT_TIMEOUT_MS, trimOutput = true } = {},
33
+ ) {
34
+ const result = spawnSync('git', args, {
35
+ cwd,
36
+ encoding: 'utf8',
37
+ timeout: timeoutMs,
38
+ env: {
39
+ ...process.env,
40
+ // A push that stops to ask for a password would hang a non-interactive
41
+ // agent run forever. Fail fast instead and let the hint explain.
42
+ GIT_TERMINAL_PROMPT: '0',
43
+ },
44
+ });
45
+ if (result.error && result.error.code === 'ENOENT') {
46
+ throw new CliError({
47
+ code: 'git_not_found',
48
+ message: 'git is not installed or not on PATH.',
49
+ exitCode: EXIT_CODES.usage,
50
+ });
51
+ }
52
+ return {
53
+ exitCode: result.status ?? 1,
54
+ stdout: trimOutput ? (result.stdout || '').trim() : (result.stdout || ''),
55
+ stderr: trimOutput ? (result.stderr || '').trim() : (result.stderr || ''),
56
+ };
57
+ }
58
+
59
+ function gitOrNull(args, options) {
60
+ const result = runGit(args, options);
61
+ return result.exitCode === 0 ? result.stdout : null;
62
+ }
63
+
64
+ function nulSeparatedGitPaths(args, repository) {
65
+ const result = runGit([...args, '-z'], { cwd: repository.toplevel, trimOutput: false });
66
+ if (result.exitCode !== 0 || !result.stdout) return [];
67
+ return result.stdout.split('\0').filter(Boolean);
68
+ }
69
+
70
+ /** Exact files `git add -A` would include, expanding untracked directories. */
71
+ function filesToAutoCommit(repository) {
72
+ return [...new Set([
73
+ ...nulSeparatedGitPaths(['diff', '--name-only'], repository),
74
+ ...nulSeparatedGitPaths(['diff', '--cached', '--name-only'], repository),
75
+ ...nulSeparatedGitPaths(['ls-files', '--others', '--exclude-standard'], repository),
76
+ ])];
77
+ }
78
+
79
+ /** Refuse files that are unsafe to publish automatically without user review. */
80
+ export function sensitiveAutoCommitFiles(repository) {
81
+ const root = resolve(repository.toplevel);
82
+ const sensitive = [];
83
+ for (const path of filesToAutoCommit(repository)) {
84
+ const absolute = resolve(root, path);
85
+ const insideRoot = relative(root, absolute);
86
+ if (insideRoot === '..' || insideRoot.startsWith(`..${sep}`) || insideRoot === '') continue;
87
+ const name = basename(path);
88
+ if (SENSITIVE_PATH.test(name) && !SAFE_ENV_TEMPLATES.test(name)) {
89
+ sensitive.push(path);
90
+ continue;
91
+ }
92
+ try {
93
+ const stat = lstatSync(absolute);
94
+ if (!stat.isFile() || stat.size > MAX_SECRET_SCAN_BYTES) continue;
95
+ const content = readFileSync(absolute, 'utf8');
96
+ if (SENSITIVE_CONTENT.some((pattern) => pattern.test(content))) sensitive.push(path);
97
+ } catch {
98
+ // Deleted files and paths racing with an editor have no content to leak.
99
+ }
100
+ }
101
+ return sensitive;
102
+ }
103
+
104
+ /**
105
+ * Parse an origin URL into {owner, repo}. Handles the three forms git remotes
106
+ * actually take: scp-style ssh, ssh:// and https://.
107
+ */
108
+ export function parseRemoteUrl(url) {
109
+ if (typeof url !== 'string' || !url) {
110
+ return null;
111
+ }
112
+ const trimmed = url.trim().replace(/\.git$/, '');
113
+ const scp = trimmed.match(/^[^@]+@([^:]+):(.+)$/);
114
+ const path = scp ? scp[2] : trimmed.replace(/^[a-z+]+:\/\/(?:[^@/]+@)?[^/]+\//i, '');
115
+ const host = scp ? scp[1] : (trimmed.match(/^[a-z+]+:\/\/(?:[^@/]+@)?([^/]+)/i) || [])[1];
116
+ const segments = path.split('/').filter(Boolean);
117
+ if (segments.length < 2) {
118
+ return null;
119
+ }
120
+ return {
121
+ host: host || null,
122
+ owner: segments[segments.length - 2],
123
+ repo: segments[segments.length - 1],
124
+ };
125
+ }
126
+
127
+ /** Everything `handover start` needs to know about where it is running. */
128
+ export function inspectRepository(cwd = process.cwd()) {
129
+ const toplevel = gitOrNull(['rev-parse', '--show-toplevel'], { cwd });
130
+ if (!toplevel) {
131
+ throw new CliError({
132
+ code: 'not_a_git_repository',
133
+ message: 'Hand-over runs from inside a git repository.',
134
+ exitCode: EXIT_CODES.usage,
135
+ hints: [{ message: 'cd into the repository you want Notis to work on, then run the command again.' }],
136
+ });
137
+ }
138
+
139
+ const branch = gitOrNull(['branch', '--show-current'], { cwd: toplevel });
140
+ if (!branch) {
141
+ throw new CliError({
142
+ code: 'detached_head',
143
+ message: 'HEAD is detached, so there is no branch to hand over.',
144
+ exitCode: EXIT_CODES.usage,
145
+ hints: [{ message: 'Check out a branch first: git switch -c my-feature' }],
146
+ });
147
+ }
148
+
149
+ const remoteUrl = gitOrNull(['remote', 'get-url', 'origin'], { cwd: toplevel });
150
+ if (!remoteUrl) {
151
+ throw new CliError({
152
+ code: 'no_origin_remote',
153
+ message: 'This repository has no "origin" remote, so the cloud agent cannot fetch the branch.',
154
+ exitCode: EXIT_CODES.usage,
155
+ hints: [{ message: 'Add one: git remote add origin <url>' }],
156
+ });
157
+ }
158
+
159
+ const status = runGit(['status', '--porcelain'], { cwd: toplevel });
160
+ const dirtyFiles = status.stdout
161
+ ? status.stdout.split('\n').map((line) => line.slice(3).trim()).filter(Boolean)
162
+ : [];
163
+
164
+ return {
165
+ toplevel,
166
+ branch,
167
+ remoteUrl,
168
+ remote: parseRemoteUrl(remoteUrl),
169
+ dirtyFiles,
170
+ head: gitOrNull(['rev-parse', 'HEAD'], { cwd: toplevel }),
171
+ upstream: gitOrNull(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'], {
172
+ cwd: toplevel,
173
+ }),
174
+ };
175
+ }
176
+
177
+ /** Commit everything in the working tree, including untracked files. */
178
+ export function commitWorkingTree(repository, message) {
179
+ const sensitiveFiles = sensitiveAutoCommitFiles(repository);
180
+ if (sensitiveFiles.length) {
181
+ throw new CliError({
182
+ code: 'sensitive_working_tree',
183
+ message: 'Refusing to publish files that may contain credentials or private keys.',
184
+ exitCode: EXIT_CODES.conflict,
185
+ details: { sensitive_files: sensitiveFiles.slice(0, 20) },
186
+ hints: [
187
+ { message: 'Review, remove, or ignore these files before handing over.' },
188
+ { message: 'To publish them deliberately, commit and push them yourself first.' },
189
+ ],
190
+ });
191
+ }
192
+ const add = runGit(['add', '-A'], { cwd: repository.toplevel });
193
+ if (add.exitCode !== 0) {
194
+ throw new CliError({
195
+ code: 'git_add_failed',
196
+ message: `Could not stage the working tree: ${add.stderr || add.stdout}`,
197
+ exitCode: EXIT_CODES.unexpected,
198
+ });
199
+ }
200
+ const commit = runGit(['commit', '-m', message], { cwd: repository.toplevel });
201
+ if (commit.exitCode !== 0) {
202
+ throw new CliError({
203
+ code: 'git_commit_failed',
204
+ message: `Could not commit the working tree: ${commit.stderr || commit.stdout}`,
205
+ exitCode: EXIT_CODES.unexpected,
206
+ hints: [{ message: 'Commit the changes yourself, then run the hand-over again.' }],
207
+ });
208
+ }
209
+ return gitOrNull(['rev-parse', 'HEAD'], { cwd: repository.toplevel });
210
+ }
211
+
212
+ /**
213
+ * Push the branch to origin. The cloud workspace fetches from origin, so an
214
+ * unpushed commit simply does not exist as far as the hand-over is concerned.
215
+ */
216
+ export function pushBranch(repository, branch) {
217
+ const args = repository.upstream
218
+ ? ['push', 'origin', branch]
219
+ : ['push', '--set-upstream', 'origin', branch];
220
+ const result = runGit(args, { cwd: repository.toplevel });
221
+ if (result.exitCode !== 0) {
222
+ throw new CliError({
223
+ code: 'git_push_failed',
224
+ message: `Could not push ${branch} to origin: ${result.stderr || result.stdout}`,
225
+ exitCode: EXIT_CODES.conflict,
226
+ hints: [
227
+ { message: 'The cloud agent works from origin, so the branch has to be pushed first.' },
228
+ { message: 'If the remote moved ahead, reconcile locally (git pull --rebase) and retry.' },
229
+ ],
230
+ });
231
+ }
232
+ return true;
233
+ }
@@ -19,6 +19,11 @@ import { createInterface } from 'node:readline/promises';
19
19
 
20
20
  import { CliError, EXIT_CODES } from './errors.js';
21
21
  import { getAuthRecovery, quoteShellArgument } from './auth-recovery.js';
22
+ import {
23
+ channelFromProfile,
24
+ cliCommandForChannel,
25
+ isReleaseChannel,
26
+ } from './channel.js';
22
27
  import {
23
28
  credentialIsExpired,
24
29
  ensureProfile,
@@ -137,6 +142,12 @@ export async function discoverCliOAuth(apiBase, fetchImpl = fetch) {
137
142
  resource: protectedResource.resource,
138
143
  clientId: protectedResource.notis_cli_client_id || 'notis_cli',
139
144
  copyPasteRedirectUri: protectedResource.notis_cli_copy_paste_redirect_uri,
145
+ // A deployment that predates channel advertising, or a local one with no
146
+ // published build, leaves this null and the profile keeps resolving its
147
+ // channel from the endpoint it authorized against.
148
+ channel: isReleaseChannel(protectedResource.notis_cli_channel)
149
+ ? protectedResource.notis_cli_channel
150
+ : null,
140
151
  authorizationEndpoint: authorizationServer.authorization_endpoint,
141
152
  tokenEndpoint: authorizationServer.token_endpoint,
142
153
  revocationEndpoint: authorizationServer.revocation_endpoint,
@@ -718,6 +729,13 @@ function persistOAuthTokenResponse(runtime, metadata, tokenResponse) {
718
729
  // only endpoint the resulting token is accepted by.
719
730
  api_base: oauthApiBase || profile.api_base,
720
731
  beta: beta ?? profile.beta,
732
+ // The deployment that just authorized this profile also names the
733
+ // published build that belongs to it. Pinning it here is what lets the
734
+ // next run correct itself without the user knowing a channel exists.
735
+ channel: isReleaseChannel(metadata.channel)
736
+ ? metadata.channel
737
+ : channelFromProfile({ ...profile, beta: beta ?? profile.beta, api_base: oauthApiBase })
738
+ ?? profile.channel,
721
739
  oauth_api_base: oauthApiBase || profile.oauth_api_base,
722
740
  oauth_resource: metadata.resource,
723
741
  oauth_access_token: tokenResponse.access_token,
@@ -784,14 +802,20 @@ function clearPendingAuthorization(runtime, file = pendingAuthorizationFile(runt
784
802
  }
785
803
  }
786
804
 
787
- function redeemCommand(profileName) {
805
+ function redeemCommand(profileName, channel) {
788
806
  return [
789
- 'npx --package @notis_ai/cli@latest -- notis',
807
+ cliCommandForChannel(channel),
790
808
  `--profile ${quoteShellArgument(profileName || 'default')}`,
791
809
  'login --code <code>',
792
810
  ].join(' ');
793
811
  }
794
812
 
813
+ function authorizationChannel(metadata, runtime, pending = null) {
814
+ return metadata.channel
815
+ || pending?.channel
816
+ || channelFromProfile({ api_base: pending?.api_base || runtime.apiBase });
817
+ }
818
+
795
819
  function updateRuntimeFromOAuthProfile(runtime, profile) {
796
820
  const oauthApiBase = getOAuthApiBase(profile);
797
821
  runtime.jwt = profile.oauth_access_token;
@@ -875,6 +899,7 @@ async function redeemAuthorizationCode(runtime, code, fetchImpl) {
875
899
  resource: pending.resource,
876
900
  clientId: pending.client_id,
877
901
  tokenEndpoint: pending.token_endpoint,
902
+ channel: pending.channel,
878
903
  };
879
904
  if (!metadata.issuer || !metadata.resource || !metadata.clientId || !metadata.tokenEndpoint) {
880
905
  throw oauthError(
@@ -961,7 +986,10 @@ export async function loginWithOAuth(runtime, options = {}, output, fetchImpl =
961
986
  scopes: pendingScopes,
962
987
  }),
963
988
  expires_in: Math.max(0, Number(pending.expires_at) - Math.floor(Date.now() / 1000)),
964
- redeem_command: redeemCommand(runtime.profileName),
989
+ redeem_command: redeemCommand(
990
+ runtime.profileName,
991
+ authorizationChannel(metadata, runtime, pending),
992
+ ),
965
993
  },
966
994
  };
967
995
  }
@@ -1010,6 +1038,7 @@ export async function loginWithOAuth(runtime, options = {}, output, fetchImpl =
1010
1038
  client_id: metadata.clientId,
1011
1039
  token_endpoint: metadata.tokenEndpoint,
1012
1040
  authorization_endpoint: metadata.authorizationEndpoint,
1041
+ channel: authorizationChannel(metadata, runtime),
1013
1042
  scopes,
1014
1043
  expires_at: Math.floor(Date.now() / 1000) + PENDING_LOGIN_TTL_SECONDS,
1015
1044
  });
@@ -1021,7 +1050,10 @@ export async function loginWithOAuth(runtime, options = {}, output, fetchImpl =
1021
1050
  agentAuthorization: {
1022
1051
  authorize_url: authorizeUrl,
1023
1052
  expires_in: PENDING_LOGIN_TTL_SECONDS,
1024
- redeem_command: redeemCommand(runtime.profileName),
1053
+ redeem_command: redeemCommand(
1054
+ runtime.profileName,
1055
+ authorizationChannel(metadata, runtime),
1056
+ ),
1025
1057
  },
1026
1058
  };
1027
1059
  }
@@ -12,6 +12,7 @@ import { homedir } from 'node:os';
12
12
  import { dirname, join, parse, resolve } from 'node:path';
13
13
  import { CliError, EXIT_CODES } from './errors.js';
14
14
  import { getAuthRecovery, quoteShellArgument } from './auth-recovery.js';
15
+ import { channelFromProfile, cliCommandForChannel, isReleaseChannel } from './channel.js';
15
16
 
16
17
  export const CONFIG_DIR = join(homedir(), '.notis');
17
18
  export const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
@@ -95,6 +96,10 @@ function normalizeProfile(rawProfile = {}) {
95
96
  return {
96
97
  api_base: typeof raw.api_base === 'string' ? raw.api_base : undefined,
97
98
  beta: typeof raw.beta === 'boolean' ? raw.beta : undefined,
99
+ // Which published CLI build this profile runs, as the deployment reported
100
+ // it at login. Unknown values are dropped rather than trusted: this key
101
+ // decides which code executes on the next run.
102
+ channel: isReleaseChannel(raw.channel) ? raw.channel : undefined,
98
103
  label: typeof raw.label === 'string' ? raw.label : undefined,
99
104
  dev_access_token:
100
105
  typeof raw.dev_access_token === 'string' ? raw.dev_access_token : undefined,
@@ -877,7 +882,7 @@ export function resolveRuntimeProfile(
877
882
  exitCode: EXIT_CODES.auth,
878
883
  hints: [{
879
884
  command: [
880
- 'npx --package @notis_ai/cli@latest -- notis',
885
+ cliCommandForChannel(channelFromProfile({ api_base: normalizedRequestedApiBase })),
881
886
  `--profile ${quoteShellArgument(profileName)}`,
882
887
  `--api-base ${quoteShellArgument(normalizedRequestedApiBase)}`,
883
888
  'login',
@@ -929,6 +934,17 @@ export function resolveRuntimeProfile(
929
934
  profileName,
930
935
  profileSource,
931
936
  profileLabel: profile.label,
937
+ // Which published build serves this profile. Carried on the runtime so
938
+ // every recovery hint prints the command that will actually run.
939
+ channel: devRuntime
940
+ ? null
941
+ : channelFromProfile(
942
+ (globalOptions.apiBase || process.env.NOTIS_API_BASE)
943
+ // An explicit route owns this invocation even when the stored
944
+ // profile is pinned to the opposite release channel.
945
+ ? { api_base: apiBase }
946
+ : { ...profile, api_base: apiBase },
947
+ ),
932
948
  apiBase,
933
949
  requestedApiBase: normalizedRequestedApiBase,
934
950
  jwt,
@@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto';
2
2
  import { createReadStream } from 'node:fs';
3
3
  import { dirname } from 'node:path';
4
4
  import { CliError, EXIT_CODES } from './errors.js';
5
+ import { delegatedContextReason } from './delegated-context.js';
5
6
  import {
6
7
  credentialIsExpired,
7
8
  getJwtExpiration,
@@ -327,9 +328,11 @@ export async function httpRequest({
327
328
  });
328
329
 
329
330
  let payload = null;
331
+ let payloadReadError = null;
330
332
  try {
331
333
  payload = await response.json();
332
- } catch {
334
+ } catch (error) {
335
+ payloadReadError = error;
333
336
  payload = null;
334
337
  }
335
338
 
@@ -350,9 +353,11 @@ export async function httpRequest({
350
353
  signal: controller.signal,
351
354
  ...(requestBody.duplex ? { duplex: requestBody.duplex } : {}),
352
355
  });
356
+ payloadReadError = null;
353
357
  try {
354
358
  payload = await response.json();
355
- } catch {
359
+ } catch (error) {
360
+ payloadReadError = error;
356
361
  payload = null;
357
362
  }
358
363
  }
@@ -376,6 +381,14 @@ export async function httpRequest({
376
381
  throw normalizeBackendError(response.status, payload, runtime);
377
382
  }
378
383
 
384
+ // A successful status is not a successful tool call until its response
385
+ // body has been read. Mutations may already have committed before a socket
386
+ // reset truncates the JSON; surface that as an ambiguous network failure
387
+ // so callers never persist an undefined result or attempt a second write.
388
+ if (response.status !== 204 && payloadReadError) {
389
+ throw payloadReadError;
390
+ }
391
+
379
392
  return {
380
393
  requestId,
381
394
  payload: payload || {},
@@ -428,6 +441,10 @@ export async function callTool({
428
441
  cwd: process.cwd(),
429
442
  agent_mode: runtime.agentMode,
430
443
  cli_version: runtime.cliVersion,
444
+ // Reported so the server can refuse work-handing tools from a run Notis
445
+ // is itself driving. Advisory -- a determined agent can unset the markers
446
+ // -- so the server treats it as one layer, not the whole guard.
447
+ ...(delegatedContextReason() ? { delegated_context: true } : {}),
431
448
  ...(runtime.debugEntitlementOverride
432
449
  ? { debug_entitlement_override: runtime.debugEntitlementOverride }
433
450
  : {}),
@@ -164,8 +164,35 @@
164
164
  });
165
165
  });
166
166
 
167
+ /**
168
+ * Records one runtime call and hands back the entry so the caller can
169
+ * stamp its outcome once the promise settles. Verify reads `ok` to tell a
170
+ * route that rendered from real data apart from one that only rendered its
171
+ * error states, so every runtime path must settle its own entry.
172
+ */
167
173
  function record(op, args) {
168
- window.__harness.runtimeCalls.push({ op, args });
174
+ const entry = { op, args, ok: null, error: null, durationMs: null };
175
+ entry.startedAt = Date.now();
176
+ window.__harness.runtimeCalls.push(entry);
177
+ return entry;
178
+ }
179
+
180
+ function settle(entry, error) {
181
+ entry.durationMs = Date.now() - entry.startedAt;
182
+ delete entry.startedAt;
183
+ entry.ok = !error;
184
+ entry.error = error ? (error.message ? String(error.message) : String(error)) : null;
185
+ }
186
+
187
+ async function tracked(entry, run) {
188
+ try {
189
+ const value = await run();
190
+ settle(entry, null);
191
+ return value;
192
+ } catch (error) {
193
+ settle(entry, error);
194
+ throw error;
195
+ }
169
196
  }
170
197
 
171
198
  async function readJsonOrSnippet(response) {
@@ -214,29 +241,58 @@
214
241
  app: descriptor.app,
215
242
  route: descriptor.route,
216
243
  context: descriptor.context || {},
217
- navigate: (args) => record('navigate', args),
244
+ navigate: (args) => {
245
+ settle(record('navigate', args), null);
246
+ },
247
+ // There is no manager chat here, so the handover is recorded and
248
+ // reported as drafted without any UI.
249
+ handover: async (payload) => {
250
+ settle(record('handover', payload), null);
251
+ return { status: 'drafted' };
252
+ },
218
253
  registerTopBarSearch: () => {},
219
254
  setTopBarSearchValue: () => {},
220
255
  setTopBarSearchLoading: () => {},
221
- listTools: async () => {
222
- record('listTools', {});
223
- return descriptor.tools || [];
224
- },
225
- callTool: async (name, args) => {
226
- record('callTool', { name, arguments: args || {} });
227
- const fixture = lookupToolFixture(name, args || {});
228
- if (fixture !== undefined) {
229
- return structuredClone(fixture);
230
- }
231
- return { ok: true, result: null };
232
- },
233
- request: async (path, options) => {
234
- record('request', { path, options });
235
- if (fixtures && fixtures.requests && Object.prototype.hasOwnProperty.call(fixtures.requests, path)) {
236
- return structuredClone(fixtures.requests[path]);
237
- }
238
- return null;
239
- },
256
+ // No change feed here, so `useDatabaseSubscription` reports live=false
257
+ // and the app keeps whatever manual refresh it offers.
258
+ subscribeDatabase: () => () => {},
259
+ // There is no cloud computer behind the harness. Answering "not
260
+ // available" rather than throwing keeps `useCloudComputer` on the same
261
+ // fallback branch a user without a cloud computer would see.
262
+ cloudComputerFacts: async () => tracked(
263
+ record('cloudComputerFacts', {}),
264
+ async () => ({
265
+ available: false,
266
+ reason: 'unsupported_host',
267
+ sandbox: null,
268
+ cli_auth: {
269
+ gh: { authenticated: null, account: null, checked_at: null, reason: 'unsupported_host' },
270
+ },
271
+ }),
272
+ ),
273
+ listTools: async () => tracked(
274
+ record('listTools', {}),
275
+ async () => descriptor.tools || [],
276
+ ),
277
+ callTool: async (name, args) => tracked(
278
+ record('callTool', { name, arguments: args || {} }),
279
+ async () => {
280
+ const fixture = lookupToolFixture(name, args || {});
281
+ if (fixture !== undefined) {
282
+ return structuredClone(fixture);
283
+ }
284
+ return { ok: true, result: null };
285
+ },
286
+ ),
287
+ request: async (path, options) => tracked(
288
+ record('request', { path, options }),
289
+ async () => {
290
+ if (fixtures && fixtures.requests && Object.prototype.hasOwnProperty.call(fixtures.requests, path)) {
291
+ return structuredClone(fixtures.requests[path]);
292
+ }
293
+ return null;
294
+ },
295
+ ),
240
296
  };
241
297
  }
242
298
 
@@ -270,32 +326,45 @@
270
326
  function liveRuntime() {
271
327
  return {
272
328
  ...stubRuntime(),
273
- listTools: async () => {
274
- record('listTools', {});
275
- const result = await runtimeQuery({ method: 'tools/list' });
276
- return result.tools || [];
277
- },
278
- callTool: async (name, args) => {
279
- record('callTool', { name, arguments: args || {} });
280
- return runtimeQuery({ method: 'tools/call', name, arguments: args || {} });
281
- },
282
- request: async (path, options) => {
283
- record('request', { path, options });
284
- const response = await fetch(`${String(apiBase).replace(/\/$/, '')}${path}`, {
285
- method: (options && options.method) || 'GET',
286
- headers: {
287
- Authorization: `Bearer ${jwt}`,
288
- ...((options && options.body) ? { 'Content-Type': 'application/json' } : {}),
289
- ...((options && options.headers) || {}),
290
- },
291
- body: options && options.body ? JSON.stringify(options.body) : undefined,
292
- });
293
- const { payload, snippet } = await readJsonOrSnippet(response);
294
- if (!response.ok) {
295
- throw new Error((payload && (payload.error || payload.message)) || snippet || `Request failed with status ${response.status}`);
296
- }
297
- return payload;
298
- },
329
+ listTools: async () => tracked(
330
+ record('listTools', {}),
331
+ async () => {
332
+ const result = await runtimeQuery({ method: 'tools/list' });
333
+ return result.tools || [];
334
+ },
335
+ ),
336
+ callTool: async (name, args) => tracked(
337
+ record('callTool', { name, arguments: args || {} }),
338
+ async () => runtimeQuery({ method: 'tools/call', name, arguments: args || {} }),
339
+ ),
340
+ // Live mode must not inherit the stub's canned answer: it would settle
341
+ // ok: true with fake data and soften the all-calls-failed assertion.
342
+ cloudComputerFacts: async (options) => tracked(
343
+ record('cloudComputerFacts', options || {}),
344
+ async () => runtimeQuery({
345
+ method: 'cloud_computer/facts',
346
+ ...(options && options.refresh ? { refresh: true } : {}),
347
+ }),
348
+ ),
349
+ request: async (path, options) => tracked(
350
+ record('request', { path, options }),
351
+ async () => {
352
+ const response = await fetch(`${String(apiBase).replace(/\/$/, '')}${path}`, {
353
+ method: (options && options.method) || 'GET',
354
+ headers: {
355
+ Authorization: `Bearer ${jwt}`,
356
+ ...((options && options.body) ? { 'Content-Type': 'application/json' } : {}),
357
+ ...((options && options.headers) || {}),
358
+ },
359
+ body: options && options.body ? JSON.stringify(options.body) : undefined,
360
+ });
361
+ const { payload, snippet } = await readJsonOrSnippet(response);
362
+ if (!response.ok) {
363
+ throw new Error((payload && (payload.error || payload.message)) || snippet || `Request failed with status ${response.status}`);
364
+ }
365
+ return payload;
366
+ },
367
+ ),
299
368
  };
300
369
  }
301
370