cawdev-cli 0.9.0

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.
@@ -0,0 +1,165 @@
1
+ // The terminal's own scrollback, with a live region pinned under it — R81.
2
+ //
3
+ // **This replaces a pane manager rather than adding a framework.** The client
4
+ // used to take the alternate screen and repaint a viewport into it, which meant
5
+ // the terminal's scroll, its wheel, its search and its copy all stopped working
6
+ // the moment you attached, and the transcript above the fold was gone. Claude
7
+ // Code's CLI does not do that, and the difference is the one people notice.
8
+ //
9
+ // So: transcript lines are PRINTED. They go into the scrollback and are never
10
+ // touched again — which is what makes wheel, `shift+PgUp`, search and copy the
11
+ // terminal's job rather than ours. Only the last few lines and the footer are
12
+ // drawn, and they are the only thing that is ever erased.
13
+ //
14
+ // The whole mechanism is three escape sequences and one invariant:
15
+ //
16
+ // **After every write, the cursor sits at column 0 of the live region's first
17
+ // row, and everything below it belongs to the live region.**
18
+ //
19
+ // Which makes the update trivial: erase from the cursor down (`ESC[0J`), print
20
+ // whatever is being committed to the scrollback, print the live region, then
21
+ // walk back up by however many rows it took.
22
+ //
23
+ // The one rule that keeps that arithmetic honest: **every live line is clipped
24
+ // to the width.** A line that wraps occupies two rows and one row of cursor
25
+ // arithmetic, and the difference between those two numbers is how a footer
26
+ // eats a transcript. Committed lines are NOT clipped — they are the terminal's
27
+ // to wrap, and its to reflow when the window changes.
28
+
29
+ import { clip, colourDepth, stripAnsi } from '../lib/ansi.mjs';
30
+
31
+ const ESC = '\x1b';
32
+ const ERASE_DOWN = `${ESC}[0J`;
33
+ const HIDE_CURSOR = `${ESC}[?25l`;
34
+ const SHOW_CURSOR = `${ESC}[?25h`;
35
+ const RESET = `${ESC}[0m`;
36
+
37
+ export class Scrollback {
38
+ /**
39
+ * @param out where to write. `process.stdout` in the real thing; anything
40
+ * with `write` in a test, which is how the arithmetic below is checked
41
+ * without a terminal.
42
+ */
43
+ constructor(out = process.stdout, {
44
+ colour = colourDepth(process.env, out) > 0,
45
+ // A dumb terminal is a TTY that cannot be repainted, and R83 needs to say
46
+ // so: everything drawn here assumes `ESC[0J` and `ESC[nA` mean something.
47
+ // Passed in rather than read here so the caller keeps one flag for "can this
48
+ // be drawn on at all", which is also what decides whether it reads keys.
49
+ tty = Boolean(out.isTTY) && process.env.TERM !== 'dumb',
50
+ } = {}) {
51
+ this.out = out;
52
+ this.drawn = 0;
53
+ this.pinned = [];
54
+ // A pipe, a `less`, a CI log. There is no cursor to move and no region to
55
+ // pin, so the live region simply stops existing and the transcript is the
56
+ // whole output — which is the honest degradation, not a lesser one.
57
+ this.tty = tty;
58
+ // **Two flags, because they are two questions.** `tty` is about a cursor;
59
+ // this is about escape codes, and `FORCE_COLOR` is somebody piping into
60
+ // something that does understand them. Where it is off, a committed line is
61
+ // stripped rather than merely un-tinted: the transcript carries the AGENT'S
62
+ // colour (R23), which our own painter has no say over, and a file full of
63
+ // `ESC[32m` is not what "legible through a pipe" means.
64
+ this.colour = colour;
65
+ }
66
+
67
+ get width() {
68
+ return this.out.columns ?? 80;
69
+ }
70
+
71
+ get height() {
72
+ return this.out.rows ?? 24;
73
+ }
74
+
75
+ /** Takes the cursor. The alternate screen is deliberately NOT entered. */
76
+ open() {
77
+ if (this.tty) {
78
+ this.out.write(HIDE_CURSOR);
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Commit lines to the scrollback and replace what is pinned, in one write.
84
+ *
85
+ * **One call rather than two, and that is not tidiness.** Printing a line and
86
+ * then setting the footer is two erase-and-redraw cycles per line: on a
87
+ * session emitting a few hundred lines a second the terminal spends its time
88
+ * on escape codes, and — worse — the footer drawn by the first of the two is
89
+ * the *previous* one, so a stale prompt line flashes under everything that is
90
+ * printed. Both were visible in a real terminal.
91
+ *
92
+ * Committed lines are not clipped and not wrapped: a long line is the
93
+ * terminal's to fold, and folding it here would freeze today's width into the
94
+ * copy somebody takes tomorrow.
95
+ */
96
+ update(lines = [], live = this.pinned) {
97
+ this.pinned = live ?? [];
98
+ this.#write(Array.isArray(lines) ? lines : [lines], this.pinned);
99
+ }
100
+
101
+ /** Commit lines under whatever is already pinned. */
102
+ print(lines) {
103
+ this.update(lines, this.pinned);
104
+ }
105
+
106
+ /** Replace what is pinned at the bottom. */
107
+ live(lines) {
108
+ this.update([], lines ?? []);
109
+ }
110
+
111
+ /**
112
+ * The window changed shape.
113
+ *
114
+ * Nothing is repaired above the cursor, and that is the point: those lines
115
+ * are the terminal's now, and it has already reflowed them the way it
116
+ * reflows every other line in the buffer. Only the live region is redrawn.
117
+ */
118
+ resize() {
119
+ if (!this.tty) return;
120
+ this.out.write('\r');
121
+ this.drawn = 0;
122
+ this.#write([], this.pinned);
123
+ }
124
+
125
+ /** Give the terminal back, leaving the transcript where it is. */
126
+ close() {
127
+ if (!this.tty) return;
128
+ this.out.write(`${ERASE_DOWN}${SHOW_CURSOR}`);
129
+ this.drawn = 0;
130
+ }
131
+
132
+ /** A line as it should be committed: closed if coloured, plain if not. */
133
+ #commit(line) {
134
+ return this.colour ? `${line}${RESET}\n` : `${stripAnsi(line)}\n`;
135
+ }
136
+
137
+ #write(committed, live) {
138
+ if (!this.tty) {
139
+ // No region, so nothing to erase and nothing to walk back over.
140
+ if (committed.length) {
141
+ this.out.write(committed.map((line) => this.#commit(line)).join(''));
142
+ }
143
+ return;
144
+ }
145
+
146
+ // A live region taller than the window would scroll the transcript off the
147
+ // top and leave the cursor arithmetic pointing at rows that are no longer
148
+ // there. One row is kept for whatever is committed next.
149
+ const shown = live.slice(-Math.max(1, this.height - 1)).map((line) => clip(line, this.width));
150
+
151
+ let out = ERASE_DOWN;
152
+ for (const line of committed) {
153
+ out += this.#commit(line);
154
+ }
155
+ for (const line of shown) {
156
+ out += `${line}${RESET}\n`;
157
+ }
158
+ if (shown.length) {
159
+ out += `${ESC}[${shown.length}A`;
160
+ }
161
+ out += '\r';
162
+ this.out.write(out);
163
+ this.drawn = shown.length;
164
+ }
165
+ }
@@ -0,0 +1,316 @@
1
+ // One select widget, written once — R83.
2
+ //
3
+ // **The CLI was the one surface that threw the agent's options away.**
4
+ // `ask_user` has carried `options` since R10 — the agent has usually already
5
+ // worked out the two or three answers it can act on — and the console renders
6
+ // them as buttons. Here the question printed and you retyped one of them,
7
+ // spelled correctly. A permission request had the same shape and the same
8
+ // problem: R60's three answers were three single keys bolted to the side of a
9
+ // scrolling transcript rather than a thing you look at and choose from.
10
+ //
11
+ // So there is ONE of these and everything that offers a choice is it: the
12
+ // question picker, the permission decision, and R81's `L` run overlay, which
13
+ // stops being its own code. A second implementation of "a cursor, some rows and
14
+ // a window that walks" is a second place for the arithmetic to be wrong by one.
15
+ //
16
+ // Three rules the widget keeps:
17
+ //
18
+ // 1. **It draws into R81's live region and never rewrites the scrollback.**
19
+ // `lines()` returns rows; the client pins them. Nothing here writes.
20
+ // 2. **Getting to free text costs one key.** `ask_user` says people can still
21
+ // answer in prose because the options are the agent's GUESS at the shape of
22
+ // the decision, and the whole value of asking a person is that they can say
23
+ // the thing that was not on the list. So the last row opens a line editor,
24
+ // and Esc from there comes back to the list rather than abandoning the
25
+ // answer.
26
+ // 3. **Where there is no cursor to move it is a numbered list read from
27
+ // stdin** — a plain prompt, not a broken repaint. {@link plainLines} and
28
+ // {@link pickFromLine} are that path, and they are the same rows.
29
+ //
30
+ // Pure, and rendered from a plain object, for R62's reason: a widget built
31
+ // inside a draw method can only be checked by looking at it, and three of R81's
32
+ // five layout bugs were found by rendering at four widths and READING them.
33
+
34
+ import { clip, keyList, painter, visibleWidth } from '../lib/ansi.mjs';
35
+
36
+ const ESC = '\x1b';
37
+
38
+ /** The row that is always last on a question, and what choosing it means. */
39
+ export const WRITE_MY_OWN = '__write-my-own__';
40
+
41
+ // Arrows, and the keys people who live in a terminal press instead of arrows.
42
+ // Tab is here because R81's run list took it and taking it away would be a
43
+ // regression nobody asked for.
44
+ const DOWN = new Set([`${ESC}[B`, `${ESC}OB`, '\t', '\x0e', 'j']);
45
+ const UP = new Set([`${ESC}[A`, `${ESC}OA`, `${ESC}[Z`, '\x10', 'k']);
46
+
47
+ /**
48
+ * A choice: a title, some rows, and a cursor.
49
+ *
50
+ * `kind` is for the caller — it is what tells the client whether a chosen row is
51
+ * an answer, a decision or a run to watch. The widget itself does not care.
52
+ */
53
+ export class Select {
54
+ constructor({ kind = 'choice', title = '', rows = [], at = 0, empty = null, hint = null }) {
55
+ this.kind = kind;
56
+ this.title = title;
57
+ this.rows = rows;
58
+ this.empty = empty;
59
+ this.hint = hint;
60
+ this.at = Math.max(0, Math.min(at, Math.max(0, rows.length - 1)));
61
+ }
62
+
63
+ get row() {
64
+ return this.rows[this.at] ?? null;
65
+ }
66
+
67
+ /** Whether the last row opens a line editor — rule 2 above. */
68
+ get freeText() {
69
+ return this.rows.some((row) => row.id === WRITE_MY_OWN);
70
+ }
71
+
72
+ move(by) {
73
+ if (!this.rows.length) {
74
+ this.at = 0;
75
+ return;
76
+ }
77
+ this.at = (this.at + by + this.rows.length) % this.rows.length;
78
+ }
79
+
80
+ /**
81
+ * One key, and what it settled.
82
+ *
83
+ * Returns null for a key that means nothing here, so the caller can decide
84
+ * whether it means something to IT — which is how the single permission keys
85
+ * keep working while the picker is open.
86
+ *
87
+ * **A digit chooses rather than merely moving.** It is the only key on this
88
+ * widget that is faster than the arrows, and making it a two-step would spend
89
+ * the whole reason somebody reached for it.
90
+ */
91
+ key(pressed) {
92
+ if (pressed === ESC) {
93
+ return { done: 'cancelled', row: null };
94
+ }
95
+ if (pressed === '\r' || pressed === '\n') {
96
+ return { done: 'chosen', row: this.row };
97
+ }
98
+ if (DOWN.has(pressed)) {
99
+ this.move(1);
100
+ return { done: null, moved: true };
101
+ }
102
+ if (UP.has(pressed)) {
103
+ this.move(-1);
104
+ return { done: null, moved: true };
105
+ }
106
+ if (/^[1-9]$/.test(pressed)) {
107
+ const at = Number(pressed) - 1;
108
+ if (at < this.rows.length) {
109
+ this.at = at;
110
+ return { done: 'chosen', row: this.rows[at] };
111
+ }
112
+ return null;
113
+ }
114
+ return null;
115
+ }
116
+
117
+ lines(width, ink = painter(3), room = 8) {
118
+ return selectLines(this, width, ink, room);
119
+ }
120
+ }
121
+
122
+ /**
123
+ * The widget, drawn.
124
+ *
125
+ * The window walks with the cursor rather than the list scrolling under it: a
126
+ * machine with twenty runs should still show the one you are on, and a question
127
+ * with a dozen options should still show the one about to be chosen.
128
+ */
129
+ export function selectLines(select, width, ink = painter(3), room = 8) {
130
+ const lines = [];
131
+ if (select.title) {
132
+ lines.push(clip(` ${ink.muted(select.title)}`, width));
133
+ }
134
+ if (!select.rows.length) {
135
+ lines.push(clip(` ${ink.muted(select.empty ?? 'nothing to choose from')}`, width));
136
+ }
137
+
138
+ const shown = Math.max(1, Math.min(select.rows.length, room));
139
+ const from = Math.max(0, Math.min(select.at - Math.floor(shown / 2), select.rows.length - shown));
140
+ select.rows.slice(from, from + shown).forEach((row, offset) => {
141
+ const at = from + offset;
142
+ lines.push(rowLine(row, {
143
+ chosen: at === select.at,
144
+ marker: row.marker ?? ' ',
145
+ // Past nine there is no digit to offer, and a number nobody can press is
146
+ // worse than a space.
147
+ number: at < 9 ? String(at + 1) : ' ',
148
+ }, width, ink));
149
+ });
150
+ if (select.rows.length > shown) {
151
+ lines.push(clip(` ${ink.muted(`… ${select.rows.length - shown} more`)}`, width));
152
+ }
153
+
154
+ lines.push(hintLine(select, width, ink));
155
+ return lines;
156
+ }
157
+
158
+ /**
159
+ * What to press.
160
+ *
161
+ * **`esc leave` has its room reserved rather than taking its chances.**
162
+ * {@link keyList} drops whole keys from the right, which is the correct
163
+ * behaviour and would drop exactly the wrong one here: leaving is the key
164
+ * somebody reaches for when they do not want any of this, and it is the last
165
+ * thing that should go when the terminal is narrow. So the others are given
166
+ * what is left over and it is written after them, in the order somebody reads.
167
+ * It is the same rule R51's banner keeps about `n refuse`.
168
+ */
169
+ function hintLine(select, width, ink) {
170
+ const leave = ink.muted('esc leave');
171
+ const room = width - visibleWidth(leave) - 4;
172
+ if (room < 10) {
173
+ return clip(` ${leave}`, width);
174
+ }
175
+ const keys = [ink.muted('↑↓ move'), ink.muted('enter choose')];
176
+ if (select.rows.length > 1) {
177
+ keys.push(ink.muted('1-9 pick'));
178
+ }
179
+ if (select.hint) {
180
+ keys.push(ink.muted(select.hint));
181
+ }
182
+ return clip(` ${keyList(keys, '', room, ink)}${ink.muted(' · ')}${leave}`, width);
183
+ }
184
+
185
+ /**
186
+ * One row, and what gives way when there is not enough of it.
187
+ *
188
+ * A row may bring its own renderer — `runLine` narrows a run's LABEL and keeps
189
+ * the reason it is queued, which is R58's lesson about which half should survive
190
+ * and is not a rule a generic row could guess. Everything else is a label, a
191
+ * `short` wording of it, and a hint beside it, and they give way in that order:
192
+ *
193
+ * 1. **The hint goes first.** It is a gloss; the label is the thing being
194
+ * chosen. Rendered at forty columns the other way round, this row read
195
+ * `2 Allow Bash(mvn *) for the rest of dies with the session`, which
196
+ * spends the last columns on the aside and cuts the answer.
197
+ * 2. **Then the wording shortens, a whole phrasing at a time.** R78's rule,
198
+ * and this is the widget where it matters most: `Allow Bash(mvn *) for th`
199
+ * describes a promise nobody made, and these words are a decision about
200
+ * what a machine may do rather than a status.
201
+ * 3. Only a terminal too narrow for the short wording on its own reaches the
202
+ * clip, and there is nothing better than a cut line to give it.
203
+ */
204
+ function rowLine(row, opts, width, ink) {
205
+ if (row.render) {
206
+ return row.render(opts, width, ink);
207
+ }
208
+ const head = `${opts.chosen ? ink.bold('❯') : ' '}${opts.marker ?? ' '}${ink.muted(opts.number ?? ' ')} `;
209
+ const room = width - visibleWidth(head);
210
+ const hint = row.hint ? ` ${row.hint}` : '';
211
+
212
+ const label = String(row.label ?? '');
213
+ const fits = (text, withHint) =>
214
+ visibleWidth(text) + (withHint ? visibleWidth(hint) : 0) <= room;
215
+
216
+ const withHint = fits(label, true);
217
+ const text = fits(label, false) || !row.short || !fits(row.short, false)
218
+ ? clip(label, Math.max(8, room))
219
+ : row.short;
220
+
221
+ return clip(
222
+ `${head}${opts.chosen ? ink.text(text) : text}${withHint ? ink.muted(hint) : ''}`,
223
+ width,
224
+ );
225
+ }
226
+
227
+ /**
228
+ * The same choice where there is no cursor to move — R62's rule, R81's rule.
229
+ *
230
+ * Through a pipe, on a dumb terminal, or anywhere the live region does not
231
+ * exist, this is what a picker is: the rows PRINTED with numbers beside them and
232
+ * a line read from stdin. Not a lesser widget — a different one, and the honest
233
+ * shape for a stream that cannot be repainted.
234
+ *
235
+ * The free-text row does not need a number here. Anything that is not a number
236
+ * IS the free text, which is what a plain prompt has always meant.
237
+ */
238
+ export function plainLines(select, { many = false } = {}) {
239
+ const lines = ['', select.title ? ` ${select.title}` : ' choose:'];
240
+ if (!select.rows.length) {
241
+ lines.push(` ${select.empty ?? 'nothing to choose from'}`);
242
+ }
243
+ select.rows.forEach((row, at) => {
244
+ // A hint that repeats the label is noise — "1) cawdev — cawdev" — and it
245
+ // happens whenever a thing's name and its identifier agree, which for a
246
+ // slug is most of the time.
247
+ const hint = row.hint && row.hint !== row.label ? ` — ${row.hint}` : '';
248
+ lines.push(` ${at + 1}) ${row.label}${hint}`);
249
+ });
250
+ // The LAST line is the instruction, and there is exactly one of it. R93 added
251
+ // a question with several answers and printed its own instruction under this
252
+ // one, so the screen said "a number:" and then "several numbers:" — the
253
+ // widget knowing both forms is what stops that.
254
+ lines.push(many
255
+ ? ' several numbers, or enter for all:'
256
+ : select.freeText
257
+ ? ' a number, or just type your answer:'
258
+ : ' a number:');
259
+ return lines;
260
+ }
261
+
262
+ /**
263
+ * A typed line, against the rows.
264
+ *
265
+ * Returns null for a line that settles nothing, so the caller asks again rather
266
+ * than guessing — except where free text is on offer, in which case a line that
267
+ * is not a number is the answer itself.
268
+ */
269
+ export function pickFromLine(select, typed) {
270
+ const text = String(typed ?? '').trim();
271
+ if (!text) {
272
+ return null;
273
+ }
274
+ if (/^[0-9]+$/.test(text)) {
275
+ const row = select.rows[Number(text) - 1];
276
+ return row ? { done: 'chosen', row } : null;
277
+ }
278
+ const free = select.rows.find((row) => row.id === WRITE_MY_OWN);
279
+ return free ? { done: 'chosen', row: free, text } : null;
280
+ }
281
+
282
+ /**
283
+ * Several rows from one typed line — R93.
284
+ *
285
+ * Here rather than in the setup walk because R83's rule is that choosing is
286
+ * this file's job, and a second parser for "1, 3" living next to the first
287
+ * would be the drift that rule exists to prevent. No free text: a question with
288
+ * more than one answer has no "write my own" row, since the rows are the
289
+ * choices and not a guess at them.
290
+ *
291
+ * Returns an empty array for a line that settles nothing, so the caller asks
292
+ * again. A number naming no row makes the whole line nothing rather than
293
+ * silently choosing the rest: somebody who typed `1 2 9` meant three, and
294
+ * giving them two of them is a wrong answer wearing a right one's clothes.
295
+ */
296
+ export function pickManyFromLine(select, typed) {
297
+ const text = String(typed ?? '').trim();
298
+ if (!text) {
299
+ return [];
300
+ }
301
+ const wanted = text.split(/[\s,]+/).filter(Boolean);
302
+ const picked = [];
303
+ for (const part of wanted) {
304
+ if (!/^[0-9]+$/.test(part)) {
305
+ return [];
306
+ }
307
+ const row = select.rows[Number(part) - 1];
308
+ if (!row) {
309
+ return [];
310
+ }
311
+ if (!picked.includes(row)) {
312
+ picked.push(row);
313
+ }
314
+ }
315
+ return picked;
316
+ }
@@ -0,0 +1,78 @@
1
+ // Where the CLI remembers who you are — R81.
2
+ //
3
+ // One file, `~/.cawdev/session.json`, mode 0600, holding the session cookies
4
+ // the platform handed back after somebody approved a sign-in in their browser.
5
+ // **What is stored is a person's session and nothing else**: no password ever
6
+ // reaches this process, and the daemon's own token is not in here — R52's rule
7
+ // that a run's credential can never be borrowed for a decision is exactly why
8
+ // these two are different files.
9
+ //
10
+ // Keyed by URL, because one machine can point at more than one cawdev and
11
+ // reusing a cookie across instances is either a 401 or, worse, not one.
12
+ //
13
+ // Zero dependencies, like everything in tools/.
14
+
15
+ import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises';
16
+ import { homedir } from 'node:os';
17
+ import { dirname, join } from 'node:path';
18
+
19
+ /** Beside the sockets: one directory a person can chmod, back up, or delete. */
20
+ export function sessionFile() {
21
+ return join(homedir(), '.cawdev', 'session.json');
22
+ }
23
+
24
+ function key(url) {
25
+ return String(url).replace(/\/+$/, '');
26
+ }
27
+
28
+ async function readAll() {
29
+ try {
30
+ const parsed = JSON.parse(await readFile(sessionFile(), 'utf8'));
31
+ return parsed && typeof parsed === 'object' ? parsed : {};
32
+ } catch {
33
+ // No file, unreadable, or half-written. None of the three is worth an
34
+ // error: the answer to all of them is "you are not signed in yet".
35
+ return {};
36
+ }
37
+ }
38
+
39
+ /** What was stored for one instance, or null. */
40
+ export async function loadSession(url) {
41
+ const stored = (await readAll())[key(url)];
42
+ if (!stored?.cookies || !stored.email) {
43
+ return null;
44
+ }
45
+ return { email: stored.email, cookies: new Map(Object.entries(stored.cookies)) };
46
+ }
47
+
48
+ /**
49
+ * Remember a session for next time.
50
+ *
51
+ * The directory is created 0700 and the file forced to 0600 **after** writing:
52
+ * `writeFile`'s mode is masked by the process umask, so asking for 0600 and
53
+ * getting 0644 is the normal outcome rather than the unusual one, and a
54
+ * world-readable session cookie is the whole thing this file must not be.
55
+ */
56
+ export async function saveSession(url, { email, cookies }) {
57
+ const all = await readAll();
58
+ all[key(url)] = {
59
+ email,
60
+ cookies: Object.fromEntries(cookies instanceof Map ? cookies : Object.entries(cookies ?? {})),
61
+ savedAt: new Date().toISOString(),
62
+ };
63
+ await mkdir(dirname(sessionFile()), { recursive: true, mode: 0o700 });
64
+ await writeFile(sessionFile(), `${JSON.stringify(all, null, 2)}\n`, { mode: 0o600 });
65
+ await chmod(sessionFile(), 0o600).catch(() => undefined);
66
+ }
67
+
68
+ /** Forget one instance. Signing out, and what a dead cookie earns. */
69
+ export async function clearSession(url) {
70
+ const all = await readAll();
71
+ if (!(key(url) in all)) {
72
+ return;
73
+ }
74
+ delete all[key(url)];
75
+ await mkdir(dirname(sessionFile()), { recursive: true, mode: 0o700 });
76
+ await writeFile(sessionFile(), `${JSON.stringify(all, null, 2)}\n`, { mode: 0o600 });
77
+ await chmod(sessionFile(), 0o600).catch(() => undefined);
78
+ }