@north-light/cli 0.3.419 → 0.3.420

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 (56) hide show
  1. package/dist/api/dto/docs.d.ts +7 -0
  2. package/dist/api/dto/nodes.d.ts +1 -0
  3. package/dist/builtin-memory/crouter-sdk/README.md +3 -3
  4. package/dist/builtin-memory/crouter-sdk/getting-started.md +2 -2
  5. package/dist/builtin-memory/crouter-sdk/guides/README.md +1 -1
  6. package/dist/builtin-memory/crouter-sdk/guides/apps-on-crouter-cloud.md +222 -3
  7. package/dist/clients/attach/input/controller.d.ts +1 -0
  8. package/dist/clients/attach/input/controller.js +2 -2
  9. package/dist/clients/attach/render/chat-view.d.ts +44 -0
  10. package/dist/clients/attach/render/chat-view.js +3 -3
  11. package/dist/clients/attach/session/frames.d.ts +2 -0
  12. package/dist/clients/attach/session/frames.js +1 -1
  13. package/dist/clients/attach/session/input-wiring.d.ts +3 -0
  14. package/dist/clients/attach/session/input-wiring.js +1 -1
  15. package/dist/clients/attach/slash/dispatch.d.ts +4 -0
  16. package/dist/clients/attach/slash/dispatch.js +2 -2
  17. package/dist/clients/attach/viewer.js +567 -563
  18. package/dist/core/command-plugins/bundle.d.ts +3 -0
  19. package/dist/core/command-plugins/bundle.js +1 -1
  20. package/dist/core/config.js +1 -1
  21. package/dist/core/fs-utils.d.ts +7 -0
  22. package/dist/core/fs-utils.js +3 -3
  23. package/dist/core/graph/package-docs.d.ts +4 -1
  24. package/dist/core/graph/package-docs.js +5 -5
  25. package/dist/core/keybindings/catalog.d.ts +1 -1
  26. package/dist/core/keybindings/catalog.js +1 -1
  27. package/dist/core/manifest.d.ts +15 -0
  28. package/dist/core/manifest.js +1 -1
  29. package/dist/core/plugin-quarantine.d.ts +16 -0
  30. package/dist/core/plugin-quarantine.js +1 -0
  31. package/dist/core/providers/bundles.js +2 -2
  32. package/dist/core/runtime/broker/rebind.d.ts +5 -0
  33. package/dist/core/runtime/broker/rebind.js +1 -1
  34. package/dist/core/runtime/broker/turn-replies.d.ts +33 -0
  35. package/dist/core/runtime/broker/turn-replies.js +1 -0
  36. package/dist/core/runtime/broker-protocol.d.ts +12 -1
  37. package/dist/core/runtime/broker.js +1 -1
  38. package/dist/core/runtime/node-read.js +3 -3
  39. package/dist/core/runtime/tmux-bindings.js +1 -1
  40. package/dist/core/runtime/turn-reply-summary.d.ts +6 -0
  41. package/dist/core/runtime/turn-reply-summary.js +11 -0
  42. package/dist/core/user-settings.d.ts +15 -0
  43. package/dist/core/user-settings.js +1 -1
  44. package/dist/daemon/api/handlers/nodes.js +1 -1
  45. package/dist/daemon/crtrd.js +6 -5
  46. package/dist/shared/turn-replies.d.ts +27 -0
  47. package/dist/shared/turn-replies.js +5 -0
  48. package/dist/types.d.ts +6 -0
  49. package/dist/types.js +1 -1
  50. package/docs/sdk/README.md +3 -3
  51. package/docs/sdk/getting-started.md +2 -2
  52. package/docs/sdk/guides/README.md +1 -1
  53. package/docs/sdk/guides/apps-on-crouter-cloud.md +218 -1
  54. package/package.json +2 -2
  55. package/packages/crouter-identity/package.json +1 -1
  56. package/runtime.lock.json +12 -12
@@ -132,4 +132,11 @@ export interface ReconcilePackagesDTO {
132
132
  path: string;
133
133
  error: string;
134
134
  }>;
135
+ /** plugin directories with a damaged manifest or command file, moved aside to `<scope>/quarantine/plugins/` */
136
+ quarantined?: Array<{
137
+ name: string;
138
+ file: string;
139
+ reason: string;
140
+ movedTo?: string;
141
+ }>;
135
142
  }
@@ -298,6 +298,7 @@ export interface NodeSnapshotDTO {
298
298
  followUp: string[];
299
299
  };
300
300
  toolGroupSummaries?: Record<string, unknown>;
301
+ turnReplies?: Record<string, unknown>;
301
302
  workingActivity?: string;
302
303
  };
303
304
  commands: {
@@ -28,7 +28,7 @@ if (run.kind === 'result') console.log(run.output_parsed.root_cause);
28
28
 
29
29
  ## Go live with an app key
30
30
 
31
- Once you have installed the SDK and tried the local flow in [[crouter-sdk/getting-started]], register the app with `crtr cloud apps register` (or the console), then generate its key and pull its settings on your server:
31
+ Once you have installed the SDK and tried the local flow in [[crouter-sdk/getting-started]], build against production: [[crouter-sdk/guides/apps-on-crouter-cloud]] takes you from signing up at `https://account.northlight.ai` to a running app on your laptop, registering in the directory console. (`crtr cloud apps register` does the same from a machine joined to your account; production does not yet let a laptop join.) On a joined machine, generate the key and pull the settings with:
32
32
 
33
33
  ```bash
34
34
  npx @north-light/sdk keygen --app <client id>
@@ -37,7 +37,7 @@ npx @north-light/sdk env --app <client id>
37
37
 
38
38
  `keygen` saves the private JWK to `local/app-key.json` (mode `0600`); keep that file out of git. With `--app`, it offers to add the public key to your registered app through your own `crtr`, as an additional key that never replaces the current one. Otherwise, click **Add key** on the app's **Sign-in** tab and paste the public JWK printed on stdout (or paste it under **Register an app** for a new app). An app authenticates only with its key; the directory issues no client secret.
39
39
 
40
- `env` reads the registration through `crtr cloud apps get` and writes `CROUTER_ISSUER`, `CROUTER_CLIENT_ID` and `APP_ORIGIN` into `local/app.env` (`--out` picks another file), keeping every other line. Both commands need this machine's `crtr` joined to your crouter cloud account; on a machine that isn't, they print the join step and exit 2. An app is configured from four variables:
40
+ `env` reads the registration through `crtr cloud apps get` and writes `CROUTER_ISSUER`, `CROUTER_CLIENT_ID` and `APP_ORIGIN` into `local/app.env` (`--out` picks another file), keeping every other line. Both commands need this machine's `crtr` joined to your Northlight account; on a machine that isn't, they print a join step and exit 2. On a laptop against production, run `npx @north-light/sdk keygen --out local/app-key.json` without `--app`, paste the public JWK in the console, and write the env file by hand. An app is configured from four variables:
41
41
 
42
42
  | Variable | Value |
43
43
  |---|---|
@@ -85,7 +85,7 @@ The directory rejects the key until its public JWK is added to the app. To chang
85
85
  | [[crouter-sdk/docker]] | `start()`, `attach()`, `connection()` from `@north-light/env-docker` |
86
86
  | [[crouter-sdk/testing]] | `FakeCloud` from `@north-light/sdk/testing`: a fake directory and runtime your real `OAuth2Client` and `Crouter` talk to through `fetch` |
87
87
  | [[crouter-sdk/release-notes]] | Behavior changes an existing app must act on, newest first |
88
- | [[crouter-sdk/guides/apps-on-crouter-cloud]] | Typed results from app runs with `runs.parse`, declines, bounding a run, `run_idle`, and waiting on a provider your app requires |
88
+ | [[crouter-sdk/guides/apps-on-crouter-cloud]] | Building and registering an app against production (console registration, keys, env file, scopes, running locally, the `crtr cloud apps` commands); typed results from app runs with `runs.parse`, declines, bounding a run, `run_idle`, and waiting on a provider your app requires |
89
89
  | [[crouter-sdk/guides/apps-with-many-users]] | Per-person runtime connections, app-owned user access, privacy, and spending |
90
90
 
91
91
  ## Phases
@@ -174,7 +174,7 @@ An outcome is returned, never thrown — including a decline and a failure. Only
174
174
 
175
175
  ## Go live with an app key
176
176
 
177
- Register your app with `crtr cloud apps register` (or in the directory console), then generate its signing key and pull its settings on your app server:
177
+ Build against production from your laptop: [[crouter-sdk/guides/apps-on-crouter-cloud]] walks the whole path. In short, sign up at `https://account.northlight.ai`, register the app in the directory console (`https://account.northlight.ai/console`) with a `http://localhost:<port>` origin and a `http://localhost:<port>/auth/callback` redirect URI while you develop, generate the key with `npx @north-light/sdk keygen --out local/app-key.json` and paste its public JWK, and write `local/app.env` by hand. Production does not yet let a laptop join as an additional machine, so `crtr cloud apps …`, `keygen --app` and `env` work today only on a machine joined to your account; your Northlight chat agent can run the `crtr cloud apps` commands for you ([[crouter-sdk/guides/apps-on-crouter-cloud]]). If you do have a joined machine, generate the key and pull its settings with:
178
178
 
179
179
  ```bash
180
180
  npx @north-light/sdk keygen --app <client id>
@@ -185,7 +185,7 @@ npx @north-light/sdk env --app <client id>
185
185
 
186
186
  `env` runs `crtr cloud apps get --app-id <client id>` and writes `CROUTER_ISSUER`, `CROUTER_CLIENT_ID`, and `APP_ORIGIN` into `local/app.env` (`--out <path>` writes another file). It updates those three lines in place, keeps every other line and comment, and creates the file with mode `0600` when it doesn't exist. It never writes `CROUTER_KEY_FILE` or anything secret. It prints the values it set and the key file your app will read.
187
187
 
188
- Both commands reach your app through this machine's `crtr`, so the machine must be joined to your crouter cloud account. On one that isn't, they say so, print the join step (create a join token on `/account/machines`, then `crtr sys join`), and exit 2.
188
+ Both commands reach your app through this machine's `crtr`, so the machine must be joined to your Northlight account. On one that isn't, they say so, print a join step and exit 2. Against production that join step does not work yet for a laptop: use `keygen` without `--app` and the console instead.
189
189
 
190
190
  An app built on the SDK is configured from four variables:
191
191
 
@@ -14,7 +14,7 @@ These recipes turn the SDK namespaces into complete applications: a bounded extr
14
14
  | [[crouter-sdk/guides/event-driven-assistant]] | Events arrive over time from a webhook, queue, or watcher. | A resident node that wakes for each event. |
15
15
  | [[crouter-sdk/guides/fan-out-pipeline]] | One task needs independent research before a synthesis. | An orchestrator's final report. |
16
16
  | [[crouter-sdk/guides/human-approval]] | A person must decide before work continues. | A node resumed by an inbox answer. |
17
- | [[crouter-sdk/guides/apps-on-crouter-cloud]] | Your app starts runs on a person's runtime and needs typed data back. | A validated result from `runs.parse`, or an explicit decline or `run_idle`. |
17
+ | [[crouter-sdk/guides/apps-on-crouter-cloud]] | You are building and registering an app, or it starts runs on a person's runtime and needs typed data back. | A validated result from `runs.parse`, or an explicit decline or `run_idle`. |
18
18
  | [[crouter-sdk/guides/apps-with-many-users]] | People interact using separate runtime connections. | App-owned access controls, attribution, and per-person spending. |
19
19
 
20
20
  Start with typed extraction for a request/response job. Use a resident node when the same assistant should react again later. Use an orchestrator only when child work can proceed independently; otherwise keep the composition in your application.
@@ -1,14 +1,233 @@
1
1
  ---
2
2
  kind: knowledge
3
- when-and-why-to-read: When an app starts runs on a person's runtime and needs
4
- data back from the agent, read this because runs.parse returns a typed,
5
- validated result and the transcript is not a data channel.
3
+ when-and-why-to-read: When you are building and registering an app on
4
+ Northlight, or an app starts runs on a person's runtime and needs data back,
5
+ read this because it takes you from sign-up to a running app against
6
+ production, and runs.parse returns a typed, validated result where the
7
+ transcript is not a data channel.
6
8
  ---
7
9
 
8
10
  # Apps on crouter cloud
9
11
 
12
+ This page first walks through [building and registering an app](#build-and-register-an-app) against production, then covers [managing it with `crtr cloud apps`](#manage-an-app-with-crtr-cloud-apps), then runs.
13
+
10
14
  An app holding a person's connection starts runs on their runtime with `runs.start`, follows them with `runs.events` or `runs.reply`, and reads them with `runs.get`. This page covers getting data back from a run. For connections and per-person access, see [[crouter-sdk/guides/apps-with-many-users]].
11
15
 
16
+ ## Build and register an app
17
+
18
+ This part takes you from nothing to an app running on your laptop against Northlight's production directory, `https://account.northlight.ai`. Your app server runs on `localhost`, you sign in to it through Northlight, and it calls your own runtime. There is no local Northlight stack to run.
19
+
20
+ You register and manage the app in the directory console, and you write the app's environment file by hand. You do not need `crtr` on your laptop. The `crtr cloud apps …` commands that automate these steps need a machine joined to your Northlight account, and production does not yet let a laptop join as an additional machine. [Manage an app with `crtr cloud apps`](#manage-an-app-with-crtr-cloud-apps) covers the two ways to use them today.
21
+
22
+ The example app texts you, so it asks for the `sms` provider's `sms:send` scope. You need Node.js 22.19 or later.
23
+
24
+ ### 1. Sign up
25
+
26
+ Open `https://account.northlight.ai/signup` in a fresh browser session (a private window, or a browser profile you have not signed in to Northlight with) and create your account. Signup texts a confirmation code to your phone, and that text is sent over iMessage, so use a phone number that has iMessage. A number that cannot receive iMessage gets "We couldn't send the text", and retrying does not help. Finish the account: the console needs an account with a runtime, and the app you build calls that runtime.
27
+
28
+ ### 2. Install the SDK
29
+
30
+
31
+ ```bash
32
+ mkdir my-app && cd my-app
33
+ npm init -y
34
+ npm i @north-light/sdk@latest
35
+ ```
36
+
37
+ If a call fails with `426 runtime_version_unsupported`, the SDK is newer than the Northlight runtimes. This can happen briefly right after a new SDK release, until production runtimes are upgraded. Install the previous version (`npm i @north-light/sdk@<previous version>`) or wait, and tell Northlight at support@northlight.ai.
38
+
39
+ ### 3. Generate the signing key
40
+
41
+ An app signs in with a key, not a client secret. From your app's folder:
42
+
43
+ ```bash
44
+ npx @north-light/sdk keygen --out local/app-key.json
45
+ ```
46
+
47
+ Run it **without `--app`**: that is the form that needs no `crtr`. It writes the private key to `local/app-key.json` (mode `0600`; keep it out of git) and prints the public JWK, one line of JSON like `{"kty":"EC","x":"…","y":"…","crv":"P-256","kid":"…","alg":"ES256","use":"sig"}`. Keep that line for step 4. Never paste the private key anywhere.
48
+
49
+ ### 4. Register the app in the console
50
+
51
+ Open `https://account.northlight.ai/console`, choose **Register an app**, and fill in:
52
+
53
+ | Field | Value |
54
+ |---|---|
55
+ | Name | Shown to people on the consent page and in their account. |
56
+ | Origin URL | Where your app runs, for example `http://localhost:3000`. `http://localhost:<port>` and `http://127.0.0.1:<port>` are accepted for development. |
57
+ | Logo URL | Required. A square image. |
58
+ | Why your app needs access | One sentence, quoted on the consent page as your words. |
59
+ | Redirect URIs | One per line, for example `http://localhost:3000/auth/callback`. Sign-in returns only to a URI listed here, matched exactly, so `localhost` and `127.0.0.1` are different URIs and the port counts. |
60
+ | Public key | Paste the public JWK from step 3. It is optional here; if you leave it empty, add it later with **Add key** on the app's **Sign-in** tab. |
61
+
62
+ Choose **Register app**. The app page shows the **Client ID**: copy it. Until the public key is added, `exchangeCode` throws an `AuthenticationError`.
63
+
64
+ ### 5. Say what the app asks for
65
+
66
+ On the app page, open **What it asks for**. The app starts with `crtr:llm` and `crtr:act` ticked and can only ask for what is ticked. Tick **Send you text messages from Northlight's number** (under "Tools from providers", scope `sms:send`), then save. Production does not tick it for you, and without it the consent page does not offer it and `providers.call` for `sms` is refused. The consent sentence and the scope string appear together on each row; take scope strings from that page and do not invent them.
67
+
68
+ #### What each scope gates
69
+
70
+ A run you start can only do what the grant holds and the run's own `scopes` list allows. Narrowing a run's `scopes` removes abilities, so pick the smallest list that works.
71
+
72
+ | Scope | A run (or your server) with it can | Without it |
73
+ |---|---|---|
74
+ | `crtr:llm` | Use the person's AI subscription for the run's model calls. List it in every run's `scopes`. | The run has no model access. |
75
+ | `crtr:act` | Start runs (`runs.start`) and use a shell: every bash call on the hosted (Linux) runtime needs it. | `runs.start` is refused with `403 scope_missing`. Ask for it in step 5 and list it in the run's `scopes`. |
76
+ | `crtr:schedule` | Create, pause, resume and run schedules. | Schedule calls are refused. |
77
+ | `crtr:memory:read:user`, `crtr:files:read:user:.`, `crtr:files:rw:user:.` | Read the person's documents beyond their public ones; read or write the person's files. | The run sees only the person's `public` documents and its own app space. |
78
+ | `sms:send` (a provider scope) | Call the `sms` provider's `send` tool, which texts the person's own verified phone. | `providers.call` is refused. |
79
+
80
+ #### Who can sign in: testing mode
81
+
82
+ A new app is in testing: it is registered as `verified: false`, only you and up to 100 testers can sign in to it, and it is not shown in the marketplace. You can sign in as yourself with no setup. To let someone else in, add them by the email or phone number on their Northlight account: `crtr cloud apps testers add --app-id <id> --email them@example.com` (or `--phone +15551234567`). An email matches only while it is verified on that account. They can sign in right away; nobody gets a message, so tell them. `testers list` shows who is on the list and `testers remove` takes someone off.
83
+
84
+ Anyone else who tries sees "<app> is still in testing", and your app's callback receives `error=access_denied` with `error_description=app_in_testing` instead of a `code`. [[crouter-sdk/client]] shows how to read it and what to show the person. Removing a tester stops their access at once, including their refresh tokens, and keeps their data; adding them back and signing in again restores it.
85
+
86
+ The `testers` leaves are `cloud` leaves like the rest, so they need a joined machine or your hosted chat agent (see [Manage an app with `crtr cloud apps`](#manage-an-app-with-crtr-cloud-apps)): a laptop cannot join production yet. Ask your chat agent to run the command, for example `crtr cloud apps testers add --app-id <id> --email them@example.com`.
87
+
88
+ When you are ready for everyone, email `support@northlight.ai` to have the app verified. Only Northlight staff can verify an app; registering never sets `verified: true`. Apps that existed before testing mode are already verified.
89
+
90
+ ### 6. Write the environment file
91
+
92
+ Create `local/app.env` by hand with the Client ID from step 4:
93
+
94
+ ```bash
95
+ CROUTER_ISSUER=https://account.northlight.ai
96
+ CROUTER_CLIENT_ID=<client id>
97
+ APP_ORIGIN=http://localhost:3000
98
+ CROUTER_KEY_FILE=local/app-key.json
99
+ ```
100
+
101
+ | Variable | Value |
102
+ |---|---|
103
+ | `CROUTER_ISSUER` | `https://account.northlight.ai` |
104
+ | `CROUTER_CLIENT_ID` | The Client ID from the app page |
105
+ | `APP_ORIGIN` | The Origin URL you registered. The redirect URI is `${APP_ORIGIN}/auth/callback` by convention; it must match a registered redirect URI exactly. |
106
+ | `CROUTER_KEY_FILE` | Path to the private key from step 3. Default `local/app-key.json` |
107
+
108
+ The file holds no secret, but keep it out of git with the key. On a machine joined to your account, `npx @north-light/sdk env --app <client id>` writes the first three for you.
109
+
110
+ ### 7. Run it locally
111
+
112
+ Create the config from those variables. The SDK reads no environment; your app passes the values in.
113
+
114
+ ```ts
115
+ import {readFile} from 'node:fs/promises';
116
+ import {OAuth2Client, MemoryConnectionStore} from '@north-light/sdk';
117
+
118
+ const store = new MemoryConnectionStore(); // one process only; use a persistent ConnectionStore in production
119
+ const oauth = new OAuth2Client({
120
+ clientId: process.env.CROUTER_CLIENT_ID!,
121
+ issuer: process.env.CROUTER_ISSUER!,
122
+ redirectUri: `${process.env.APP_ORIGIN}/auth/callback`,
123
+ privateKey: JSON.parse(await readFile(process.env.CROUTER_KEY_FILE ?? 'local/app-key.json', 'utf8')),
124
+ store,
125
+ });
126
+ ```
127
+
128
+ Start the server with the file loaded, for example `node --env-file=local/app.env server.js`.
129
+
130
+ **Sign in.** The login route redirects to Northlight; the callback exchanges the code on your server and stores the person's connection. This example uses Express and an in-memory session, which are only a sketch:
131
+
132
+ ```ts
133
+ import express from 'express';
134
+ import {randomBytes} from 'node:crypto';
135
+
136
+ const app = express();
137
+ const pending = new Map<string, {codeVerifier: string; nonce: string}>(); // keyed by state; use a real session
138
+
139
+ app.get('/auth/login', async (_req, res) => {
140
+ const {url, codeVerifier, state, nonce} = await oauth.authorizeUrl({
141
+ scopes: ['openid', 'profile', 'crtr:llm', 'crtr:act', 'sms:send'],
142
+ state: randomBytes(16).toString('base64url'),
143
+ });
144
+ pending.set(state, {codeVerifier, nonce});
145
+ res.redirect(303, url);
146
+ });
147
+
148
+ app.get('/auth/callback', async (req, res) => {
149
+ const state = String(req.query.state);
150
+ const saved = pending.get(state);
151
+ if (!saved || typeof req.query.code !== 'string') return void res.status(400).send('Start sign-in again.');
152
+ pending.delete(state);
153
+ const connection = await oauth.exchangeCode({
154
+ code: req.query.code, codeVerifier: saved.codeVerifier,
155
+ state, expectedState: state, nonce: saved.nonce,
156
+ });
157
+ await store.save(connection);
158
+ res.redirect(303, `/?user=${encodeURIComponent(connection.userId)}`); // set your own session instead
159
+ });
160
+
161
+ app.listen(3000);
162
+ ```
163
+
164
+ Open `http://localhost:3000/auth/login`. Northlight asks you to sign in and shows the consent page; choose Allow, and the browser returns to your callback with a `code` (and `iss`) in the query. Request only the scopes you ticked in step 5.
165
+
166
+ **Start a run and call the `sms` provider.** `oauth.client(userId)` opens a client on that person's runtime from their stored connection:
167
+
168
+ ```ts
169
+ const runtime = await oauth.client(userId);
170
+
171
+ const started = await runtime.runs.start({prompt: 'Reply with exactly the word: pong', scopes: ['crtr:llm', 'crtr:act']});
172
+ const run = await runtime.runs.wait(started.run_id); // status 'idle' once the reply is done
173
+
174
+ // Text the signed-in person's own phone through the sms provider.
175
+ const sent = await runtime.providers.call({
176
+ provider: 'sms',
177
+ tool: 'send',
178
+ arguments: {body: 'Hello from my first Northlight app.'},
179
+ });
180
+ // sent.message_id is the message's id
181
+ ```
182
+
183
+ `send` takes a required `body` (and an optional `file`). It has no recipient argument: it texts the verified phone of the person who signed in to your app, from Northlight's number, and allows at most 50 texts per person per UTC day. `runtime.providers.tools('sms')` lists the provider's tools. [Getting a result back from a run](#getting-a-result-back-from-a-run) covers `runs.parse`.
184
+
185
+ If a run or call throws because the person's connection ended, handle it in one place with `onRefusal`; see [[crouter-sdk/guides/apps-with-many-users]].
186
+
187
+ ### 8. Test without a network
188
+
189
+ Test your app with `FakeCloud` from `@north-light/sdk/testing`. Your real `OAuth2Client` and `Crouter` talk to it through `fetch`, so tests never touch production or send a text. See [[crouter-sdk/testing]].
190
+
191
+ ## Manage an app with `crtr cloud apps`
192
+
193
+ Everything in the console has a `crtr cloud apps` command: they are leaves of Northlight's `cloud` provider, and they run as you. Two things to know today:
194
+
195
+ - **A laptop cannot run them yet.** The `cloud` commands appear on a machine joined to your Northlight account, and production does not yet let a laptop join as an additional machine. That is coming. Until it ships, do the steps above in the console and write the env file by hand. Do not follow older instructions to open `/account/machines` or to run `crtr sys join` against production: that page does not exist yet. `keygen --app` and `npx @north-light/sdk env` need the same joined machine, so use `keygen` without `--app`, as in step 3.
196
+ - **Your Northlight chat agent can run them.** Open `https://chat.northlight.ai`, and ask your chat agent to run the command. It runs as you on your hosted runtime and prints the real output. Verified on production: `register`, `set-scopes`, `keys add`, `get`, `mine`, `asks`, `set-listing`, `set-requires` and `update`. For `keys add`, generate the key on your laptop as in step 3 and give the agent only the public JWK. Never ask it to generate your key: it would live on the hosted disk. If a command fails once with `launcher refused launch_failed`, run it again.
197
+
198
+ For example, ask the agent: "Run `crtr cloud apps register --name Texty --origin http://localhost:3000 --logo https://example.com/logo.png --justification "Texts you a summary of your day." --redirect-uri http://localhost:3000/auth/callback` and show me the raw output." Then `set-scopes --app-id <id> --scope crtr:llm --scope crtr:act --scope sms:send` and `keys add --app-id <id> --jwk '<public JWK>'`.
199
+
200
+ `--app-id` takes `app:<client id>` or the bare client id. The leaves:
201
+
202
+ | Command | What it does |
203
+ |---|---|
204
+ | `crtr cloud apps mine` | Lists the apps you manage. |
205
+ | `crtr cloud apps register --name … --origin … --logo … --justification … --redirect-uri …` | Registers an app and answers its registration, including `env` (the issuer, client id and origin for step 6). The `--redirect-uri` flag repeats. |
206
+ | `crtr cloud apps get --app-id <id>` | Shows the registration. |
207
+ | `crtr cloud apps update --app-id <id> --name … / --origin … / --logo … / --justification …` | Changes the profile fields you pass. |
208
+ | `crtr cloud apps set-redirect-uris --app-id <id> --redirect-uri …` | Replaces the whole redirect URI list; repeat the flag for each. |
209
+ | `crtr cloud apps asks --app-id <id>` | Lists what an app may ask for, with whether it asks now. Large sources such as `integrations` print as a count. Narrow it with `--query sms:send`, `--source sms`, `--group providers\|apps\|agents\|space`, or `--query <text>`; for example `crtr cloud apps asks --app-id <id> --source sms`. |
210
+ | `crtr cloud apps set-scopes --app-id <id> --scope …` | Replaces the scope list. Repeat `--scope` for every scope; `--clear` empties it; `crtr:act` needs `crtr:llm`. |
211
+ | `crtr cloud apps set-requires --app-id <id> --provider …` | Names providers your app does not work without (for example `context`); `--clear` empties it. |
212
+ | `crtr cloud apps keys add --app-id <id> --jwk '<public JWK>'` | Adds a signing key beside any existing one. Answers `created: false` if that key is already on the app. |
213
+ | `crtr cloud apps set-listing --app-id <id> --login-start-url … --text-welcome …` (also `--listed`, `--price`) | Sets the listing fields; pass at least one. A loopback `--login-start-url` is accepted. `--listed true` was not run during this guide's checks. |
214
+ | `crtr cloud apps testers add --app-id <id> --email …` (or `--phone …`) | Lets that person sign in while the app is in testing; at most 100. |
215
+ | `crtr cloud apps testers list --app-id <id>` | Lists the testers. |
216
+ | `crtr cloud apps testers remove --app-id <id> --email …` (or `--phone …`) | Removes a tester and revokes their grants for the app. |
217
+ | `crtr cloud apps delete --app-id <id> --confirm <client id>` | Deletes the app and removes every person's grant for it. Only you at a joined machine's terminal can run it: an agent, including your chat agent, is refused with `provider_refused`. |
218
+
219
+ ### Deleting an app
220
+
221
+ The console has no control for deleting an app. `crtr cloud apps delete` is the way, and it runs only from a joined machine's terminal, which a laptop cannot be yet. Until then, email `support@northlight.ai` with the client id: Northlight staff can delete an app with an operator command. Deleting removes every person's grant for the app, so each runtime deletes the app's runs, space and schedules, and the app's next refresh for them fails with `invalid_grant`. It cannot be undone, and apps that hold the provider role cannot be deleted. Reusing an app for tests is usually simpler: change its redirect URIs and keys instead.
222
+
223
+ ### Let people find and install it
224
+
225
+ `login_start_url` is where Northlight starts sign-in for a person who installs your app from a link. In the console, open **Profile**, and under **Marketplace** set **Sign-in start URL**, **Text welcome** (one or two plain sentences, at most 120 characters, no links) and **Listed in the marketplace**. Your `/auth/login` route from step 7 is a valid start URL. Agents can install an app for people by text only while it is listed.
226
+
227
+ ### Rotate keys
228
+
229
+ Add a new key beside the old one, switch, verify with a real sign-in, then retire the old key. Use `keygen --out local/app-key-next.json` without `--app`, paste its public JWK with **Add key** on the **Sign-in** tab, and retire the old one there. [[crouter-sdk/getting-started]] has the full sequence.
230
+
12
231
  ## Getting a result back from a run
13
232
 
14
233
  Pass an `output_schema` and call `runs.parse`. The runtime arms the schema on the run's root agent. The agent must submit a value that validates against it. The run settles with that value, and `runs.parse` returns it as `output_parsed`, typed from your schema:
@@ -21,6 +21,7 @@ export interface InputControllerHooks {
21
21
  onNodeMetadata?: () => void;
22
22
  onToggleToolsExpand?: () => void;
23
23
  onFoldTools?: () => void;
24
+ onShowWorking?: () => void;
24
25
  onToggleThinking?: () => void;
25
26
  onRequest?: (frame: ReadOpRequest) => Promise<BrokerDataFrame>;
26
27
  onMountPicker?: (component: Component) => void;
@@ -1,7 +1,7 @@
1
- var p=Object.defineProperty;var r=(d,e)=>p(d,"name",{value:e,configurable:!0});import{spawn as u}from"node:child_process";import{readFileSync as f,unlinkSync as k,writeFileSync as g}from"node:fs";import{tmpdir as y}from"node:os";import{join as P}from"node:path";import{BROKER_READ_CAPS as v,encodeFrame as x}from"../../../core/runtime/broker-protocol.js";import{classifySubmit as w}from"../../conversation/submit.js";import{findRefTokens as C}from"../../../shared/inline-ref-grammar.js";import{dispatchSlashCommand as T,isSlashCommand as m}from"../slash/dispatch.js";import{viewerOperationForFrame as S}from"./capabilities.js";import{readClipboardImage as b,writeClipboardImageToFile as I}from"./clipboard-image.js";import{InputOverlayOwner as E}from"./overlay-owner.js";import{announceFallbackEditor as A,NO_EDITOR_MESSAGE as D,resolveEditor as q}from"../../../core/editor-resolver.js";const M=500,F=v.maxLineBytes-4*1024*1024;class z{static{r(this,"InputController")}tui;editor;keybindings;hooks;capabilities;overlays;state;lastEscapeAt=0;pastedImages=new Map;pasteSeq=0;constructor(e,t,o,i,s){this.tui=e,this.editor=t,this.keybindings=o,this.hooks=i,this.capabilities=s,this.overlays=new E(e,t,o,{send:r(n=>this.emitCommand(n),"send"),notify:r(n=>this.notify(n),"notify"),onDialogResponse:i.onDialogResponse,onRequest:i.onRequest,onMountPicker:i.onMountPicker,onUnmountPicker:i.onUnmountPicker,state:r(()=>this.state,"state")}),this.wire()}cycleLadder(e){this.emitCommand({type:"cycle_ladder",direction:e})}sendInterrupting(e){return this.emitDrive({type:"prompt",text:e,interrupt:!0})}abortTurn(){return this.state?.isStreaming!==!0?!1:this.emitCommand({type:"abort"})}attachDialog(e){this.overlays.attachDialog(e)}dismissDialog(e){this.overlays.dismissDialog(e)}setState(e){this.state=e}openFileReviewPicker(e,t,o){this.overlays.openFileReviewPicker(e,t,o)}openAuthPicker(e){return this.overlays.openAuthPicker(e)}send=r(e=>{this.emitCommand(e)},"send");emitCommand(e){const t=S(e),o=t===void 0?void 0:this.capabilities.refusal(t);return o===void 0?(this.hooks.onCommand(e),!0):(this.notify(o),!1)}openBrokerPicker(e){const t=this.capabilities.refusal("engine-drive");if(t!==void 0){this.notify(t);return}e()}wire(){this.editor.onSubmit=t=>this.handleSubmit(t),this.editor.onEscape=()=>{if(this.state?.isStreaming){this.emitCommand({type:"abort"});return}if(this.editor.getText().trim())return;const t=Date.now();t-this.lastEscapeAt<M?(this.lastEscapeAt=0,this.openBrokerPicker(()=>{this.overlays.openTreePicker()})):this.lastEscapeAt=t},this.editor.onPasteImage=()=>{this.handlePaste()};const e=this.editor;e.onPasteImagePath=t=>(this.attachImage(t),!0),this.editor.onAction("app.session.new",()=>this.emitCommand({type:"new_session"})),this.editor.onAction("app.model.cycleForward",()=>this.emitCommand({type:"cycle_model",direction:"forward"})),this.editor.onAction("app.model.cycleBackward",()=>this.emitCommand({type:"cycle_model",direction:"backward"})),this.editor.onAction("app.thinking.cycle",()=>this.emitCommand({type:"cycle_thinking"})),this.editor.onAction("app.message.followUp",()=>this.handleFollowUp()),this.editor.onAction("app.message.dequeue",()=>{this.handleDequeue()}),this.editor.onAction("app.tools.expand",()=>this.hooks.onToggleToolsExpand?.()),this.editor.onAction("app.thinking.toggle",()=>this.hooks.onToggleThinking?.()),this.editor.onAction("app.editor.external",()=>{this.openExternalEditor()}),this.editor.onAction("app.model.select",()=>this.openBrokerPicker(()=>{this.overlays.openModelPicker()})),this.editor.onAction("app.session.tree",()=>this.openBrokerPicker(()=>{this.overlays.openTreePicker()})),this.editor.onAction("app.session.fork",()=>this.openBrokerPicker(()=>{this.overlays.openForkPicker()})),this.editor.onAction("app.session.resume",()=>this.openBrokerPicker(()=>{this.overlays.openSessionPicker()}))}slashContext(){return{send:this.send,notify:r(e=>this.notify(e),"notify"),capabilities:this.capabilities,state:this.state,cwd:process.cwd(),onGraph:this.hooks.onGraph,onInbox:this.hooks.onInbox,onHelp:this.hooks.onHelp,onQuit:this.hooks.onQuit,onCopy:this.hooks.onCopy,onCopyTarget:this.hooks.onCopyTarget,onToggleMouse:this.hooks.onToggleMouse,onColor:this.hooks.onColor,onNodeMetadata:this.hooks.onNodeMetadata,onFoldTools:this.hooks.onFoldTools,openModelPicker:r(()=>this.openBrokerPicker(()=>{this.overlays.openModelPicker()}),"openModelPicker"),openSessionPicker:r(()=>this.openBrokerPicker(()=>{this.overlays.openSessionPicker()}),"openSessionPicker"),openForkPicker:r(()=>this.openBrokerPicker(()=>{this.overlays.openForkPicker()}),"openForkPicker"),openTreePicker:r(()=>this.openBrokerPicker(()=>{this.overlays.openTreePicker()}),"openTreePicker"),openSettingsPicker:r(()=>this.openBrokerPicker(()=>{this.overlays.openSettingsPicker()}),"openSettingsPicker"),openScopedModelsPicker:r(()=>this.openBrokerPicker(()=>{this.overlays.openScopedModelsPicker()}),"openScopedModelsPicker"),openMcpPicker:r(e=>this.overlays.openMcpPicker(e),"openMcpPicker"),openLoginPicker:this.hooks.openLoginPicker,openLogoutPicker:this.hooks.openLogoutPicker}}recordCommandUse(e){const t=this.hooks.frecency;if(!t)return;const o=e.slice(1).split(/\s/,1)[0];o&&t.record(`command:${o}`)}recordCompletionUse(e){const t=this.hooks.frecency;if(!t)return;const o=e.trim();if(m(o)){this.recordCommandUse(o);return}const i=this.hooks.knownRefNames?.();if(!i||i.size===0)return;const s=new Set;for(const n of C(e))i.has(n.candidate)&&!s.has(n.candidate)&&(s.add(n.candidate),t.record(`ref:${n.candidate}`))}handleSubmit(e){const t=e.trim();if(!t)return;if(m(t)&&T(t,this.slashContext())){this.recordCommandUse(t),this.editor.setText("");return}const o=this.hooks.isDormant?.()===!0;if(o&&(t.startsWith("/")||t.startsWith("!"))){this.notify("This node is parked \u2014 submit a message to wake it; broker commands need a live engine");return}const i=w(e,{isDormant:o,canWakeDormant:o&&this.capabilities.permits("wake-dormant")&&this.hooks.onDormantSubmit!==void 0,isStreaming:this.state?.isStreaming===!0});if(i.kind==="empty")return;if(i.kind==="dormant"){const a=this.hooks.onDormantSubmit;if(a===void 0)return;a(this.expandImagePlaceholders(i.text)),this.recordCompletionUse(e),this.editor.addToHistory(i.trimmed),this.editor.setText(""),this.clearPastedImages(),this.hooks.onPromptSent?.();return}if(i.kind==="bash"){if(!this.emitDrive({type:"bash",command:i.command,excludeFromContext:i.excludeFromContext}))return;this.editor.addToHistory(i.trimmed),this.editor.setText("");return}const s=i.kind==="slash"?this.state?.isStreaming===!0?"steer":"prompt":i.kind;if(s!=="steer"&&s!=="prompt")return;const n=this.expandImagePlaceholders(i.kind==="slash"?e:i.text);this.emitDrive(s==="steer"?{type:"steer",text:n}:{type:"prompt",text:n})&&(this.recordCompletionUse(e),this.editor.addToHistory(i.trimmed),this.editor.setText(""),this.clearPastedImages(),this.hooks.onPromptSent?.())}handleFollowUp(){const e=this.editor.getText(),t=e.trim();t&&this.emitDrive({type:"follow_up",text:this.expandImagePlaceholders(e)})&&(this.editor.addToHistory(t),this.editor.setText(""),this.clearPastedImages())}async handleDequeue(){const e=this.capabilities.refusal("dequeue");if(e!==void 0){this.notify(e);return}const t=this.hooks.onRequest;if(t===void 0){this.notify("Restoring queued messages isn't available in this viewer");return}let o;try{o=await t({type:"dequeue"})}catch(s){this.notify(`Could not restore queued messages: ${s instanceof Error?s.message:String(s)}`);return}if(o.kind!=="dequeue"){this.notify("Unexpected reply restoring queued messages");return}const i=[...o.steering,...o.followUp];if(i.length===0){this.notify("No queued messages to restore");return}this.editor.setText([i.join(`
1
+ var p=Object.defineProperty;var r=(d,e)=>p(d,"name",{value:e,configurable:!0});import{spawn as u}from"node:child_process";import{readFileSync as f,unlinkSync as k,writeFileSync as g}from"node:fs";import{tmpdir as y}from"node:os";import{join as P}from"node:path";import{BROKER_READ_CAPS as v,encodeFrame as w}from"../../../core/runtime/broker-protocol.js";import{classifySubmit as x}from"../../conversation/submit.js";import{findRefTokens as C}from"../../../shared/inline-ref-grammar.js";import{dispatchSlashCommand as S,isSlashCommand as m}from"../slash/dispatch.js";import{viewerOperationForFrame as T}from"./capabilities.js";import{readClipboardImage as b,writeClipboardImageToFile as I}from"./clipboard-image.js";import{InputOverlayOwner as E}from"./overlay-owner.js";import{announceFallbackEditor as A,NO_EDITOR_MESSAGE as D,resolveEditor as q}from"../../../core/editor-resolver.js";const M=500,F=v.maxLineBytes-4*1024*1024;class z{static{r(this,"InputController")}tui;editor;keybindings;hooks;capabilities;overlays;state;lastEscapeAt=0;pastedImages=new Map;pasteSeq=0;constructor(e,t,o,i,s){this.tui=e,this.editor=t,this.keybindings=o,this.hooks=i,this.capabilities=s,this.overlays=new E(e,t,o,{send:r(n=>this.emitCommand(n),"send"),notify:r(n=>this.notify(n),"notify"),onDialogResponse:i.onDialogResponse,onRequest:i.onRequest,onMountPicker:i.onMountPicker,onUnmountPicker:i.onUnmountPicker,state:r(()=>this.state,"state")}),this.wire()}cycleLadder(e){this.emitCommand({type:"cycle_ladder",direction:e})}sendInterrupting(e){return this.emitDrive({type:"prompt",text:e,interrupt:!0})}abortTurn(){return this.state?.isStreaming!==!0?!1:this.emitCommand({type:"abort"})}attachDialog(e){this.overlays.attachDialog(e)}dismissDialog(e){this.overlays.dismissDialog(e)}setState(e){this.state=e}openFileReviewPicker(e,t,o){this.overlays.openFileReviewPicker(e,t,o)}openAuthPicker(e){return this.overlays.openAuthPicker(e)}send=r(e=>{this.emitCommand(e)},"send");emitCommand(e){const t=T(e),o=t===void 0?void 0:this.capabilities.refusal(t);return o===void 0?(this.hooks.onCommand(e),!0):(this.notify(o),!1)}openBrokerPicker(e){const t=this.capabilities.refusal("engine-drive");if(t!==void 0){this.notify(t);return}e()}wire(){this.editor.onSubmit=t=>this.handleSubmit(t),this.editor.onEscape=()=>{if(this.state?.isStreaming){this.emitCommand({type:"abort"});return}if(this.editor.getText().trim())return;const t=Date.now();t-this.lastEscapeAt<M?(this.lastEscapeAt=0,this.openBrokerPicker(()=>{this.overlays.openTreePicker()})):this.lastEscapeAt=t},this.editor.onPasteImage=()=>{this.handlePaste()};const e=this.editor;e.onPasteImagePath=t=>(this.attachImage(t),!0),this.editor.onAction("app.session.new",()=>this.emitCommand({type:"new_session"})),this.editor.onAction("app.model.cycleForward",()=>this.emitCommand({type:"cycle_model",direction:"forward"})),this.editor.onAction("app.model.cycleBackward",()=>this.emitCommand({type:"cycle_model",direction:"backward"})),this.editor.onAction("app.thinking.cycle",()=>this.emitCommand({type:"cycle_thinking"})),this.editor.onAction("app.message.followUp",()=>this.handleFollowUp()),this.editor.onAction("app.message.dequeue",()=>{this.handleDequeue()}),this.editor.onAction("app.tools.expand",()=>this.hooks.onToggleToolsExpand?.()),this.editor.onAction("app.thinking.toggle",()=>this.hooks.onToggleThinking?.()),this.editor.onAction("app.editor.external",()=>{this.openExternalEditor()}),this.editor.onAction("app.model.select",()=>this.openBrokerPicker(()=>{this.overlays.openModelPicker()})),this.editor.onAction("app.session.tree",()=>this.openBrokerPicker(()=>{this.overlays.openTreePicker()})),this.editor.onAction("app.session.fork",()=>this.openBrokerPicker(()=>{this.overlays.openForkPicker()})),this.editor.onAction("app.session.resume",()=>this.openBrokerPicker(()=>{this.overlays.openSessionPicker()}))}slashContext(){return{send:this.send,notify:r(e=>this.notify(e),"notify"),capabilities:this.capabilities,state:this.state,cwd:process.cwd(),onGraph:this.hooks.onGraph,onInbox:this.hooks.onInbox,onHelp:this.hooks.onHelp,onQuit:this.hooks.onQuit,onCopy:this.hooks.onCopy,onCopyTarget:this.hooks.onCopyTarget,onToggleMouse:this.hooks.onToggleMouse,onColor:this.hooks.onColor,onNodeMetadata:this.hooks.onNodeMetadata,onFoldTools:this.hooks.onFoldTools,onShowWorking:this.hooks.onShowWorking,openModelPicker:r(()=>this.openBrokerPicker(()=>{this.overlays.openModelPicker()}),"openModelPicker"),openSessionPicker:r(()=>this.openBrokerPicker(()=>{this.overlays.openSessionPicker()}),"openSessionPicker"),openForkPicker:r(()=>this.openBrokerPicker(()=>{this.overlays.openForkPicker()}),"openForkPicker"),openTreePicker:r(()=>this.openBrokerPicker(()=>{this.overlays.openTreePicker()}),"openTreePicker"),openSettingsPicker:r(()=>this.openBrokerPicker(()=>{this.overlays.openSettingsPicker()}),"openSettingsPicker"),openScopedModelsPicker:r(()=>this.openBrokerPicker(()=>{this.overlays.openScopedModelsPicker()}),"openScopedModelsPicker"),openMcpPicker:r(e=>this.overlays.openMcpPicker(e),"openMcpPicker"),openLoginPicker:this.hooks.openLoginPicker,openLogoutPicker:this.hooks.openLogoutPicker}}recordCommandUse(e){const t=this.hooks.frecency;if(!t)return;const o=e.slice(1).split(/\s/,1)[0];o&&t.record(`command:${o}`)}recordCompletionUse(e){const t=this.hooks.frecency;if(!t)return;const o=e.trim();if(m(o)){this.recordCommandUse(o);return}const i=this.hooks.knownRefNames?.();if(!i||i.size===0)return;const s=new Set;for(const n of C(e))i.has(n.candidate)&&!s.has(n.candidate)&&(s.add(n.candidate),t.record(`ref:${n.candidate}`))}handleSubmit(e){const t=e.trim();if(!t)return;if(m(t)&&S(t,this.slashContext())){this.recordCommandUse(t),this.editor.setText("");return}const o=this.hooks.isDormant?.()===!0;if(o&&(t.startsWith("/")||t.startsWith("!"))){this.notify("This node is parked \u2014 submit a message to wake it; broker commands need a live engine");return}const i=x(e,{isDormant:o,canWakeDormant:o&&this.capabilities.permits("wake-dormant")&&this.hooks.onDormantSubmit!==void 0,isStreaming:this.state?.isStreaming===!0});if(i.kind==="empty")return;if(i.kind==="dormant"){const a=this.hooks.onDormantSubmit;if(a===void 0)return;a(this.expandImagePlaceholders(i.text)),this.recordCompletionUse(e),this.editor.addToHistory(i.trimmed),this.editor.setText(""),this.clearPastedImages(),this.hooks.onPromptSent?.();return}if(i.kind==="bash"){if(!this.emitDrive({type:"bash",command:i.command,excludeFromContext:i.excludeFromContext}))return;this.editor.addToHistory(i.trimmed),this.editor.setText("");return}const s=i.kind==="slash"?this.state?.isStreaming===!0?"steer":"prompt":i.kind;if(s!=="steer"&&s!=="prompt")return;const n=this.expandImagePlaceholders(i.kind==="slash"?e:i.text);this.emitDrive(s==="steer"?{type:"steer",text:n}:{type:"prompt",text:n})&&(this.recordCompletionUse(e),this.editor.addToHistory(i.trimmed),this.editor.setText(""),this.clearPastedImages(),this.hooks.onPromptSent?.())}handleFollowUp(){const e=this.editor.getText(),t=e.trim();t&&this.emitDrive({type:"follow_up",text:this.expandImagePlaceholders(e)})&&(this.editor.addToHistory(t),this.editor.setText(""),this.clearPastedImages())}async handleDequeue(){const e=this.capabilities.refusal("dequeue");if(e!==void 0){this.notify(e);return}const t=this.hooks.onRequest;if(t===void 0){this.notify("Restoring queued messages isn't available in this viewer");return}let o;try{o=await t({type:"dequeue"})}catch(s){this.notify(`Could not restore queued messages: ${s instanceof Error?s.message:String(s)}`);return}if(o.kind!=="dequeue"){this.notify("Unexpected reply restoring queued messages");return}const i=[...o.steering,...o.followUp];if(i.length===0){this.notify("No queued messages to restore");return}this.editor.setText([i.join(`
2
2
 
3
3
  `),this.editor.getText()].filter(s=>s.trim()).join(`
4
4
 
5
- `)),this.tui.requestRender(),this.notify(`Restored ${i.length} queued message${i.length>1?"s":""} to editor`)}emitDrive(e){try{const t=Buffer.byteLength(x(e));if(t>F)return this.notify(`Message too large to send (${Math.round(t/(1024*1024))} MiB) \u2014 shorten the text or remove an attached image`),!1}catch{return this.notify("Message could not be encoded"),!1}return this.emitCommand(e)}async handlePaste(){try{const e=await b();if(!e){this.notify("No image in the clipboard");return}if(!e.image){this.notify(e.note??"Image not pasted");return}this.attachImage(I(e.image),e.note)}catch{this.notify("Could not read the clipboard image")}}attachImage(e,t){const o=++this.pasteSeq;this.pastedImages.set(o,e);const i=this.editor.getText();this.editor.insertTextAtCursor(`${i&&!/\s$/.test(i)?" ":""}[Image #${o}] `),this.notify(t?`Image #${o} attached (${t})`:`Image #${o} attached`),this.tui.requestRender()}expandImagePlaceholders(e){return e.replace(/\[Image #(\d+)\]/g,(t,o)=>this.pastedImages.get(Number(o))??t)}clearPastedImages(){this.pastedImages.clear(),this.pasteSeq=0}async openExternalEditor(){const e=q();if(e===null){this.notify(D);return}const t=e.command,o=this.editor,i=P(y(),`crtr-attach-editor-${Date.now()}.pi.md`);try{g(i,o.getExpandedText?.()??this.editor.getText(),"utf-8"),this.tui.stop();const[s,...n]=t.split(" ");process.stdout.write(`Launching external editor: ${t}
5
+ `)),this.tui.requestRender(),this.notify(`Restored ${i.length} queued message${i.length>1?"s":""} to editor`)}emitDrive(e){try{const t=Buffer.byteLength(w(e));if(t>F)return this.notify(`Message too large to send (${Math.round(t/(1024*1024))} MiB) \u2014 shorten the text or remove an attached image`),!1}catch{return this.notify("Message could not be encoded"),!1}return this.emitCommand(e)}async handlePaste(){try{const e=await b();if(!e){this.notify("No image in the clipboard");return}if(!e.image){this.notify(e.note??"Image not pasted");return}this.attachImage(I(e.image),e.note)}catch{this.notify("Could not read the clipboard image")}}attachImage(e,t){const o=++this.pasteSeq;this.pastedImages.set(o,e);const i=this.editor.getText();this.editor.insertTextAtCursor(`${i&&!/\s$/.test(i)?" ":""}[Image #${o}] `),this.notify(t?`Image #${o} attached (${t})`:`Image #${o} attached`),this.tui.requestRender()}expandImagePlaceholders(e){return e.replace(/\[Image #(\d+)\]/g,(t,o)=>this.pastedImages.get(Number(o))??t)}clearPastedImages(){this.pastedImages.clear(),this.pasteSeq=0}async openExternalEditor(){const e=q();if(e===null){this.notify(D);return}const t=e.command,o=this.editor,i=P(y(),`crtr-attach-editor-${Date.now()}.pi.md`);try{g(i,o.getExpandedText?.()??this.editor.getText(),"utf-8"),this.tui.stop();const[s,...n]=t.split(" ");process.stdout.write(`Launching external editor: ${t}
6
6
  The viewer resumes when it exits.
7
7
  `),A(e);const a=await new Promise(h=>{const c=u(s,[...n,i],{stdio:"inherit",shell:process.platform==="win32"});c.on("error",()=>h(null)),c.on("close",l=>h(l))});a===null&&e.source==="fallback"&&this.notify(`Could not launch ${t} \u2014 set $EDITOR to an editor you have installed`),a===0&&this.editor.setText(f(i,"utf-8").replace(/\n$/,""))}catch(s){this.notify(`External editor failed: ${s instanceof Error?s.message:String(s)}`)}finally{try{k(i)}catch{}this.tui.start(),this.tui.requestRender(!0)}}notify(e){this.hooks.onNotice?.(e)}}export{z as InputController};
@@ -3,6 +3,7 @@ import { type AgentSessionEvent } from '@earendil-works/pi-coding-agent';
3
3
  import type { BrokerSnapshot } from '../../../core/runtime/broker-protocol.js';
4
4
  import { type AttachPalette } from '../config.js';
5
5
  import { type ToolGroupSummary } from '../../../shared/tool-groups.js';
6
+ import { type TurnReply } from '../../../shared/turn-replies.js';
6
7
  import { type ViewportSource } from './measured-container.js';
7
8
  import type { CondensedHistoryMode } from '../../../types.js';
8
9
  export type ToolDisplayToggle = {
@@ -112,6 +113,21 @@ export declare class ChatView {
112
113
  /** Groups exist only while building/rendering the full-fidelity live region. */
113
114
  private readonly groups;
114
115
  private openGroup;
116
+ /** Consolidated replies (`consolidate_replies`), read once per attach. */
117
+ private readonly consolidateReplies;
118
+ /** `/show-working`: expand every turn's working. Viewer-local, not persisted. */
119
+ private showWorking;
120
+ private readonly turnReplies;
121
+ private readonly turns;
122
+ private openReplyTurn;
123
+ /** The turn the live region opens in when it begins mid-turn (the condensed
124
+ * region held its person's message), else the pre-message `start` turn. */
125
+ private carriedTurn;
126
+ /** >0 while appending components that never join a turn (the person's
127
+ * message, turn chrome, errors, condensed history). */
128
+ private outsideTurns;
129
+ /** Components the turn fold currently hides. */
130
+ private turnHidden;
115
131
  private readonly groupSummaries;
116
132
  /** Node id → display name for every node spawned in this transcript, so a
117
133
  * later group's finish notice can name the child rather than its id. */
@@ -202,6 +218,11 @@ export declare class ChatView {
202
218
  * supersedes this generation.
203
219
  */
204
220
  private buildCondensedBlock;
221
+ /** Consolidated replies in condensed history: each finished turn renders as
222
+ * the person's message, its reply, then any cycle dividers it spanned. A turn
223
+ * still open at the end continues into the live region, so it is carried there
224
+ * instead of rendered. Returns false when a newer welcome superseded the build. */
225
+ private buildCondensedTurns;
205
226
  /** Defer a live broker frame when a snapshot build is in flight. Returns true
206
227
  * (and enqueues the replay closure) while building so the caller returns
207
228
  * without mutating the half-rebuilt view; false when the caller may proceed.
@@ -228,6 +249,27 @@ export declare class ChatView {
228
249
  * flight is never hidden or minimized. Only a settled group with a broker-known
229
250
  * summary collapses; incomplete/failed generation leaves its folded call lines. */
230
251
  private applyToolDisplay;
252
+ /** `/show-working` (Alt+C → z → w): expand or fold the working behind every
253
+ * consolidated reply. Returns the new state, or null when the
254
+ * `consolidate_replies` experiment is off for this attach. */
255
+ toggleShowWorking(): boolean | null;
256
+ /** A broker rewrite landed for one turn. Stored first: it may precede the
257
+ * turn's events, and snapshot rebuilds defer it through the writer gate. */
258
+ applyTurnReply(key: string, reply: TurnReply): void;
259
+ /** Every turn member the fold hides right now. The live turn keeps what is
260
+ * still in motion — the streaming message and in-flight tools — visible
261
+ * below its working row. */
262
+ private turnFoldedComponents;
263
+ private beginTurn;
264
+ /** Whether an append in progress lands in the live region of a consolidating view. */
265
+ private consolidatingHere;
266
+ private currentTurn;
267
+ /** A settled assistant message with prose becomes part of its turn's reply. */
268
+ private recordTurnProse;
269
+ /** Re-render one turn's reply from its prose and the broker's latest rewrite. */
270
+ private refreshTurn;
271
+ private refreshWorkingRow;
272
+ private replyMessage;
231
273
  /** Ctrl+T / app.thinking.toggle — flip thinking-block visibility and apply it to
232
274
  * every assistant message (the in-flight streaming bubble is itself a child of
233
275
  * the container, so the loop covers it too). Returns the new HIDDEN state. */
@@ -242,6 +284,7 @@ export declare class ChatView {
242
284
  fullOutputPath?: string;
243
285
  }): void;
244
286
  private addMessageToChat;
287
+ private appendUserMessage;
245
288
  /** Render an assistant reply plus its initially-hidden boundary separator.
246
289
  * A turn that ended on its own abort is rendered as the interrupt it is,
247
290
  * matching the live seam above, so re-reading the transcript later never
@@ -255,6 +298,7 @@ export declare class ChatView {
255
298
  /** An answer the person typed in their inbox renders exactly like a message
256
299
  * they typed here, and closes the run around it the same way. */
257
300
  private appendHumanAnswer;
301
+ private appendHumanAnswerParts;
258
302
  /** Only the full-fidelity trailing region gets summary groups. Condensed
259
303
  * history intentionally drops tool traffic, so grouping there would create
260
304
  * phantom summary rows and cross the live/condensed boundary. */