@softov/ahpc 0.1.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.
Files changed (111) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +296 -0
  3. package/dist/src/ahp/channels.d.ts +146 -0
  4. package/dist/src/ahp/channels.js +423 -0
  5. package/dist/src/ahp/connection.d.ts +656 -0
  6. package/dist/src/ahp/connection.js +1 -0
  7. package/dist/src/ahp/fake.d.ts +69 -0
  8. package/dist/src/ahp/fake.js +1996 -0
  9. package/dist/src/ahp/live.d.ts +160 -0
  10. package/dist/src/ahp/live.js +2641 -0
  11. package/dist/src/ahp/operate.d.ts +36 -0
  12. package/dist/src/ahp/operate.js +56 -0
  13. package/dist/src/ahp/publish.d.ts +39 -0
  14. package/dist/src/ahp/publish.js +370 -0
  15. package/dist/src/ahp/status.d.ts +23 -0
  16. package/dist/src/ahp/status.js +23 -0
  17. package/dist/src/ahp/types.d.ts +745 -0
  18. package/dist/src/ahp/types.js +27 -0
  19. package/dist/src/app.d.ts +54 -0
  20. package/dist/src/app.js +369 -0
  21. package/dist/src/blocks.d.ts +88 -0
  22. package/dist/src/blocks.js +102 -0
  23. package/dist/src/cli/main.d.ts +13 -0
  24. package/dist/src/cli/main.js +1537 -0
  25. package/dist/src/cli/render.d.ts +43 -0
  26. package/dist/src/cli/render.js +91 -0
  27. package/dist/src/config.d.ts +63 -0
  28. package/dist/src/config.js +45 -0
  29. package/dist/src/connect.d.ts +53 -0
  30. package/dist/src/connect.js +69 -0
  31. package/dist/src/control.d.ts +189 -0
  32. package/dist/src/control.js +2241 -0
  33. package/dist/src/diff.d.ts +44 -0
  34. package/dist/src/diff.js +111 -0
  35. package/dist/src/flags.d.ts +28 -0
  36. package/dist/src/flags.js +76 -0
  37. package/dist/src/main.d.ts +10 -0
  38. package/dist/src/main.js +54 -0
  39. package/dist/src/schedule.d.ts +46 -0
  40. package/dist/src/schedule.js +140 -0
  41. package/dist/src/screens.d.ts +79 -0
  42. package/dist/src/screens.js +1322 -0
  43. package/dist/src/state.d.ts +355 -0
  44. package/dist/src/state.js +531 -0
  45. package/dist/src/terminal.d.ts +44 -0
  46. package/dist/src/terminal.js +101 -0
  47. package/dist/src/tui.d.ts +92 -0
  48. package/dist/src/tui.js +372 -0
  49. package/dist/src/view/automations.d.ts +31 -0
  50. package/dist/src/view/automations.js +76 -0
  51. package/dist/src/view/bood/art.d.ts +90 -0
  52. package/dist/src/view/bood/art.js +152 -0
  53. package/dist/src/view/bood/beetle.d.ts +7 -0
  54. package/dist/src/view/bood/beetle.js +123 -0
  55. package/dist/src/view/bood/bunny.d.ts +8 -0
  56. package/dist/src/view/bood/bunny.js +228 -0
  57. package/dist/src/view/bood/cat.d.ts +9 -0
  58. package/dist/src/view/bood/cat.js +358 -0
  59. package/dist/src/view/bood/crab.d.ts +7 -0
  60. package/dist/src/view/bood/crab.js +121 -0
  61. package/dist/src/view/bood/index.d.ts +29 -0
  62. package/dist/src/view/bood/index.js +25 -0
  63. package/dist/src/view/bood/motion.d.ts +178 -0
  64. package/dist/src/view/bood/motion.js +298 -0
  65. package/dist/src/view/bood/owl.d.ts +9 -0
  66. package/dist/src/view/bood/owl.js +285 -0
  67. package/dist/src/view/bood/registry.d.ts +36 -0
  68. package/dist/src/view/bood/registry.js +218 -0
  69. package/dist/src/view/bood/render.d.ts +31 -0
  70. package/dist/src/view/bood/render.js +196 -0
  71. package/dist/src/view/bood/sprite.d.ts +18 -0
  72. package/dist/src/view/bood/sprite.js +130 -0
  73. package/dist/src/view/bood/sprout.d.ts +8 -0
  74. package/dist/src/view/bood/sprout.js +121 -0
  75. package/dist/src/view/bood/types.d.ts +158 -0
  76. package/dist/src/view/bood/types.js +57 -0
  77. package/dist/src/view/bubble.d.ts +75 -0
  78. package/dist/src/view/bubble.js +86 -0
  79. package/dist/src/view/changes.d.ts +35 -0
  80. package/dist/src/view/changes.js +22 -0
  81. package/dist/src/view/composer.d.ts +64 -0
  82. package/dist/src/view/composer.js +192 -0
  83. package/dist/src/view/controls.d.ts +44 -0
  84. package/dist/src/view/controls.js +49 -0
  85. package/dist/src/view/creature.d.ts +69 -0
  86. package/dist/src/view/creature.js +83 -0
  87. package/dist/src/view/customizations.d.ts +28 -0
  88. package/dist/src/view/customizations.js +53 -0
  89. package/dist/src/view/details.d.ts +65 -0
  90. package/dist/src/view/details.js +65 -0
  91. package/dist/src/view/filediff.d.ts +29 -0
  92. package/dist/src/view/filediff.js +24 -0
  93. package/dist/src/view/files.d.ts +31 -0
  94. package/dist/src/view/files.js +39 -0
  95. package/dist/src/view/hitl.d.ts +43 -0
  96. package/dist/src/view/hitl.js +171 -0
  97. package/dist/src/view/icons.d.ts +13 -0
  98. package/dist/src/view/icons.js +71 -0
  99. package/dist/src/view/picker.d.ts +42 -0
  100. package/dist/src/view/picker.js +71 -0
  101. package/dist/src/view/sessionhead.d.ts +41 -0
  102. package/dist/src/view/sessionhead.js +60 -0
  103. package/dist/src/view/sessions.d.ts +34 -0
  104. package/dist/src/view/sessions.js +61 -0
  105. package/dist/src/view/terminal.d.ts +30 -0
  106. package/dist/src/view/terminal.js +32 -0
  107. package/dist/src/view/toolcall.d.ts +27 -0
  108. package/dist/src/view/toolcall.js +48 -0
  109. package/dist/src/view/transcript.d.ts +50 -0
  110. package/dist/src/view/transcript.js +60 -0
  111. package/package.json +72 -0
@@ -0,0 +1,36 @@
1
+ /** Running a changeset operation, including the part where the host says no. */
2
+ import type { HostConnection } from './connection.js';
3
+ import type { ChangesetOperationTarget } from './types.js';
4
+ /**
5
+ * Run one of a changeset's operations, asking for write access if it is refused.
6
+ *
7
+ * Above the seam on purpose. Both host implementations refuse the same way, so
8
+ * doing this once here is what makes the scripted host and a live daemon
9
+ * behave identically from a screen's point of view - and it is a decision
10
+ * rather than plumbing: `ask` is what turns a silent retry into a question,
11
+ * and a shell and a full-screen client want different answers to that.
12
+ *
13
+ * The request is sent back **verbatim**. The host named a resource - the file
14
+ * for an operation on one, the project for an operation on the whole changeset
15
+ * - and asking about a different one is asking the wrong question.
16
+ *
17
+ * Exactly one retry. A second refusal after a grant means the host meant it,
18
+ * and looping on denials is how a button becomes a hang.
19
+ */
20
+ export declare function operate(host: HostConnection, changeset: string, operationId: string, options?: {
21
+ target?: ChangesetOperationTarget;
22
+ /**
23
+ * Whether to go ahead and ask for the access the refusal named.
24
+ *
25
+ * Answering `false` leaves the original refusal to be thrown, which is a
26
+ * real outcome: somebody was asked whether to let a host write to their
27
+ * repository and said no.
28
+ */
29
+ ask?(request: {
30
+ uri: string;
31
+ write?: boolean;
32
+ read?: boolean;
33
+ }): Promise<boolean> | boolean;
34
+ }): Promise<{
35
+ message?: string;
36
+ }>;
@@ -0,0 +1,56 @@
1
+ /** Running a changeset operation, including the part where the host says no. */
2
+ /**
3
+ * A refusal that names the way out of itself.
4
+ *
5
+ * `-32009` with a `resourceRequest` payload in `data.request`: the host is
6
+ * saying not "no" but "not until you ask", and the payload is the asking. A
7
+ * client that only knew how to press the button would draw one that fails and
8
+ * cannot explain itself.
9
+ */
10
+ const unlockable = (error) => {
11
+ const held = error;
12
+ if (held.code !== -32009)
13
+ return undefined;
14
+ const request = held.data?.request;
15
+ if (typeof request !== 'object' || request === null)
16
+ return undefined;
17
+ const asked = request;
18
+ return typeof asked.uri === 'string' ? asked : undefined;
19
+ };
20
+ /**
21
+ * Run one of a changeset's operations, asking for write access if it is refused.
22
+ *
23
+ * Above the seam on purpose. Both host implementations refuse the same way, so
24
+ * doing this once here is what makes the scripted host and a live daemon
25
+ * behave identically from a screen's point of view - and it is a decision
26
+ * rather than plumbing: `ask` is what turns a silent retry into a question,
27
+ * and a shell and a full-screen client want different answers to that.
28
+ *
29
+ * The request is sent back **verbatim**. The host named a resource - the file
30
+ * for an operation on one, the project for an operation on the whole changeset
31
+ * - and asking about a different one is asking the wrong question.
32
+ *
33
+ * Exactly one retry. A second refusal after a grant means the host meant it,
34
+ * and looping on denials is how a button becomes a hang.
35
+ */
36
+ export async function operate(host, changeset, operationId, options = {}) {
37
+ if (!host.invoke)
38
+ throw new Error('This host connection cannot run changeset operations.');
39
+ const run = () => host.invoke(changeset, operationId, options.target);
40
+ try {
41
+ return await run();
42
+ }
43
+ catch (error) {
44
+ const asked = unlockable(error);
45
+ if (!asked || !host.requestResource)
46
+ throw error;
47
+ const wanted = asked;
48
+ if (options.ask && !(await options.ask(wanted)))
49
+ throw error;
50
+ await host.requestResource(wanted.uri, {
51
+ ...(wanted.read === true ? { read: true } : {}),
52
+ ...(wanted.write === true ? { write: true } : {}),
53
+ });
54
+ return await run();
55
+ }
56
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * The authority a client publishes under is its own `clientId`.
3
+ *
4
+ * `<scheme>://<clientId>/…` is how the reference host addresses a
5
+ * client-served resource, and a host routes by reading the authority and
6
+ * matching it against the connection that sent it - so a fixed authority
7
+ * reaches nothing. The scheme is `virtual:`, which is what the specification's
8
+ * examples and both conformance suites use.
9
+ */
10
+ export declare function publishedUnder(clientId: string): string;
11
+ export interface Published {
12
+ /** The directory served, or null when nothing was published. */
13
+ readonly root: string | null;
14
+ /** Whether a host may write into it. Off unless asked for. */
15
+ readonly writable: boolean;
16
+ /** The prefix this is addressed under, once a client id is known. */
17
+ readonly prefix: string;
18
+ /** Say which connection this is, so the URIs a host routes on are right. */
19
+ as(clientId: string): Published;
20
+ /** Per-method handlers, in the shape the protocol client composes. */
21
+ handlers(): Record<string, (params: unknown) => Promise<unknown>>;
22
+ }
23
+ /** A refusal carrying the code the protocol declares for it. */
24
+ export declare class PublishRefusal extends Error {
25
+ readonly code: number;
26
+ constructor(code: number, message: string);
27
+ }
28
+ /**
29
+ * A published directory, or one that refuses everything.
30
+ *
31
+ * `root` null is the default and the safe one: every method answers `-32009`
32
+ * naming what would be needed, which is a refusal rather than an absence - the
33
+ * host asked something this client understands and declined.
34
+ */
35
+ export declare function publish(options?: {
36
+ root?: string;
37
+ writable?: boolean;
38
+ clientId?: string;
39
+ }): Published;
@@ -0,0 +1,370 @@
1
+ /*
2
+ * What this client serves back to a host.
3
+ *
4
+ * AHP is symmetrical: `subscriptions.md` says the same nine `resource*`
5
+ * methods plus `createResourceWatch` may be initiated by the server, and the
6
+ * registry's example of what they are for is fetching a client-published URI -
7
+ * `virtual://my-client/...`. The .NET conformance test publishes
8
+ * `virtual://native-aot/resource` and the TypeScript one `virtual://client/thing`,
9
+ * so the scheme shape is the specification's rather than one chosen here.
10
+ *
11
+ * Nothing is served unless a directory was named on the command line. A client
12
+ * that offered its filesystem to whatever host it happened to connect to would
13
+ * be a mistake rather than a feature, and `-32009 PermissionDenied` is the
14
+ * declared refusal - the receiver enforcing access, which the specification
15
+ * says is the receiver's job whichever peer initiated.
16
+ */
17
+ import { lstat, mkdir, open, readdir, realpath, rename, rm, stat } from 'node:fs/promises';
18
+ import path from 'node:path';
19
+ import { constants } from 'node:fs';
20
+ /**
21
+ * The authority a client publishes under is its own `clientId`.
22
+ *
23
+ * `<scheme>://<clientId>/…` is how the reference host addresses a
24
+ * client-served resource, and a host routes by reading the authority and
25
+ * matching it against the connection that sent it - so a fixed authority
26
+ * reaches nothing. The scheme is `virtual:`, which is what the specification's
27
+ * examples and both conformance suites use.
28
+ */
29
+ export function publishedUnder(clientId) {
30
+ return `virtual://${clientId}/`;
31
+ }
32
+ /** JSON-RPC codes this answers with, as `commands.ts` declares them. */
33
+ const NOT_FOUND = -32008;
34
+ const PERMISSION_DENIED = -32009;
35
+ const ALREADY_EXISTS = -32010;
36
+ const CONFLICT = -32011;
37
+ const tagOf = (size, mtimeMs) => `W/"${size.toString(16)}-${Math.trunc(mtimeMs).toString(16)}"`;
38
+ /** A refusal carrying the code the protocol declares for it. */
39
+ export class PublishRefusal extends Error {
40
+ code;
41
+ constructor(code, message) {
42
+ super(message);
43
+ this.code = code;
44
+ this.name = 'PublishRefusal';
45
+ }
46
+ }
47
+ /**
48
+ * A published directory, or one that refuses everything.
49
+ *
50
+ * `root` null is the default and the safe one: every method answers `-32009`
51
+ * naming what would be needed, which is a refusal rather than an absence - the
52
+ * host asked something this client understands and declined.
53
+ */
54
+ export function publish(options = {}) {
55
+ const root = options.root === undefined ? null : path.resolve(options.root);
56
+ const writable = options.writable === true;
57
+ /*
58
+ * Until a connection exists there is no id to publish under.
59
+ *
60
+ * `ahpc` is the placeholder and is deliberately one a host will not route:
61
+ * `as()` is called with the real `clientId` before anything can be asked
62
+ * for, and a prefix that happened to work without it would hide the day it
63
+ * was not called.
64
+ */
65
+ const PREFIX = publishedUnder(options.clientId ?? 'ahpc');
66
+ const writes = new Map();
67
+ /**
68
+ * The file a `virtual://<clientId>/...` names, or a refusal.
69
+ *
70
+ * Resolved and then checked to be inside the published directory, because
71
+ * `..` in a URI is a host asking for the rest of the disk. The check is on
72
+ * the resolved path rather than on the text: `a/../../etc` is only visibly
73
+ * outside once it has been resolved.
74
+ */
75
+ const where = async (uri, linkItself = false) => {
76
+ if (root === null) {
77
+ throw new PublishRefusal(PERMISSION_DENIED, 'This client publishes nothing. Start it with --publish.');
78
+ }
79
+ /*
80
+ * The publication's own root, with or without the trailing slash.
81
+ *
82
+ * RFC 3986 6.2.3: where an authority is present, an empty path is
83
+ * equivalent to `/`. `virtual://<clientId>` and `virtual://<clientId>/`
84
+ * are one resource, so refusing the first is this publisher being wrong
85
+ * about a URI rather than the caller being sloppy - and a caller cannot
86
+ * fix it, because anything composing the root out of a client id read off
87
+ * `initialize` produces the bare form.
88
+ */
89
+ const bare = PREFIX.slice(0, -1);
90
+ const rest = typeof uri !== 'string' ? null
91
+ : uri === bare ? ''
92
+ : uri.startsWith(PREFIX) ? uri.slice(PREFIX.length)
93
+ : null;
94
+ if (rest === null) {
95
+ throw new PublishRefusal(PERMISSION_DENIED, `This client serves only ${PREFIX}`);
96
+ }
97
+ /*
98
+ * Resolved against the root, and a leading slash is why that is not
99
+ * enough on its own: `virtual://<id>//etc/passwd` leaves `/etc/passwd`,
100
+ * which `path.resolve` treats as absolute and returns unchanged. The
101
+ * containment check below is what refuses it, so it is a guard rather
102
+ * than a tidy-up.
103
+ */
104
+ const inside = path.resolve(root, decodeURIComponent(rest));
105
+ if (inside !== root && !inside.startsWith(`${root}${path.sep}`)) {
106
+ throw new PublishRefusal(PERMISSION_DENIED, 'That is outside what this client published.');
107
+ }
108
+ const boundary = await realpath(root);
109
+ let ancestor = inside;
110
+ const missing = [];
111
+ for (;;) {
112
+ try {
113
+ const resolved = await realpath(ancestor);
114
+ if (resolved !== boundary && !resolved.startsWith(`${boundary}${path.sep}`)) {
115
+ throw new PublishRefusal(PERMISSION_DENIED, 'That is outside what this client published.');
116
+ }
117
+ if (linkItself && missing.length === 0) {
118
+ return path.join(await realpath(path.dirname(inside)), path.basename(inside));
119
+ }
120
+ return path.join(resolved, ...missing.reverse());
121
+ }
122
+ catch (error) {
123
+ if (error instanceof PublishRefusal)
124
+ throw error;
125
+ // A dangling link has a destination too. Never treat it as a new file.
126
+ if (await lstat(ancestor).then((entry) => entry.isSymbolicLink(), () => false)) {
127
+ throw new PublishRefusal(PERMISSION_DENIED, 'That is a dangling symbolic link.');
128
+ }
129
+ if (error.code !== 'ENOENT')
130
+ throw error;
131
+ const parent = path.dirname(ancestor);
132
+ if (parent === ancestor)
133
+ throw error;
134
+ missing.push(path.basename(ancestor));
135
+ ancestor = parent;
136
+ }
137
+ }
138
+ };
139
+ const mutable = () => {
140
+ if (!writable)
141
+ throw new PublishRefusal(PERMISSION_DENIED, 'What this client published is read-only.');
142
+ };
143
+ const gone = (uri) => {
144
+ throw new PublishRefusal(NOT_FOUND, `${String(uri)} is not there.`);
145
+ };
146
+ /*
147
+ * `ahpd` carries a second copy of this, in its `resources.ts`.
148
+ *
149
+ * `resourceWrite` is symmetrical - a host asks a client for one exactly as a
150
+ * client asks a host - so both ends need the same flags, the same order of
151
+ * preconditions and the same append and insert arithmetic. This client does
152
+ * not depend on that package and is not going to, so the copy is deliberate.
153
+ * What is not deliberate is fixing one and not the other: everything here
154
+ * was wrong in both at once, and was corrected in both at once.
155
+ */
156
+ const write = async (at, uri, params) => {
157
+ const before = writes.get(at) ?? Promise.resolve();
158
+ const operation = before.catch(() => { }).then(async () => {
159
+ const mode = params.mode === 'append' || params.mode === 'insert' ? params.mode : 'truncate';
160
+ const position = typeof params.position === 'number' ? params.position : 0;
161
+ const createOnly = params.createOnly === true;
162
+ const ifMatch = typeof params.ifMatch === 'string' ? params.ifMatch : undefined;
163
+ const incoming = params.encoding === 'base64'
164
+ ? Buffer.from(String(params.data), 'base64')
165
+ : Buffer.from(String(params.data), 'utf8');
166
+ let flags = (mode === 'truncate' && position === 0 ? constants.O_WRONLY : constants.O_RDWR) | constants.O_NOFOLLOW;
167
+ if (ifMatch === undefined)
168
+ flags |= constants.O_CREAT;
169
+ if (createOnly && ifMatch === undefined)
170
+ flags |= constants.O_EXCL;
171
+ const file = await open(at, flags).catch((error) => {
172
+ if (createOnly && error.code === 'EEXIST') {
173
+ throw new PublishRefusal(ALREADY_EXISTS, `${String(uri)} already exists.`);
174
+ }
175
+ if (ifMatch !== undefined && error.code === 'ENOENT') {
176
+ throw new PublishRefusal(CONFLICT, `${String(uri)} has changed since ${ifMatch}.`);
177
+ }
178
+ if (error.code === 'ENOENT')
179
+ return gone(uri);
180
+ // The two refusals the flags produce, said in this client's own words:
181
+ // `O_NOFOLLOW` answers a final link with `ELOOP`, and a directory
182
+ // opened for writing answers `EISDIR`. Both are a refusal to write
183
+ // what was asked for, and neither is useful to a host as an errno.
184
+ if (error.code === 'ELOOP') {
185
+ throw new PublishRefusal(PERMISSION_DENIED, `${String(uri)} is a symbolic link.`);
186
+ }
187
+ if (error.code === 'EISDIR') {
188
+ throw new PublishRefusal(PERMISSION_DENIED, `${String(uri)} is a directory.`);
189
+ }
190
+ throw new PublishRefusal(PERMISSION_DENIED, `Could not write ${String(uri)}: ${error.message}`);
191
+ });
192
+ try {
193
+ if (createOnly && ifMatch !== undefined) {
194
+ throw new PublishRefusal(ALREADY_EXISTS, `${String(uri)} already exists.`);
195
+ }
196
+ if (ifMatch !== undefined) {
197
+ // Off the open descriptor, so what is compared is the file about to
198
+ // be written rather than whatever the name pointed at a moment ago.
199
+ const found = await file.stat();
200
+ if (tagOf(found.size, found.mtimeMs) !== ifMatch) {
201
+ throw new PublishRefusal(CONFLICT, `${String(uri)} has changed since ${ifMatch}.`);
202
+ }
203
+ }
204
+ const held = mode === 'truncate' && position === 0 ? Buffer.alloc(0) : await file.readFile();
205
+ let output;
206
+ if (mode === 'append') {
207
+ const cut = Math.max(0, held.length - Math.max(0, position));
208
+ output = Buffer.concat([held.subarray(0, cut), incoming, held.subarray(cut)]);
209
+ }
210
+ else if (mode === 'insert') {
211
+ const cut = Math.min(Math.max(0, position), held.length);
212
+ output = Buffer.concat([held.subarray(0, cut), incoming, held.subarray(cut)]);
213
+ }
214
+ else {
215
+ const cut = Math.min(Math.max(0, position), held.length);
216
+ output = Buffer.concat([held.subarray(0, cut), incoming]);
217
+ }
218
+ let offset = 0;
219
+ while (offset < output.length) {
220
+ const { bytesWritten } = await file.write(output, offset, output.length - offset, offset);
221
+ if (bytesWritten === 0) {
222
+ throw new PublishRefusal(PERMISSION_DENIED, `Could not finish writing ${String(uri)}.`);
223
+ }
224
+ offset += bytesWritten;
225
+ }
226
+ await file.truncate(output.length);
227
+ }
228
+ finally {
229
+ await file.close();
230
+ }
231
+ });
232
+ writes.set(at, operation);
233
+ try {
234
+ await operation;
235
+ }
236
+ finally {
237
+ if (writes.get(at) === operation)
238
+ writes.delete(at);
239
+ }
240
+ };
241
+ return {
242
+ root,
243
+ writable,
244
+ prefix: PREFIX,
245
+ as: (clientId) => publish({
246
+ ...(root === null ? {} : { root }),
247
+ ...(writable ? { writable } : {}),
248
+ clientId,
249
+ }),
250
+ handlers: () => ({
251
+ resourceRead: async (params) => {
252
+ const uri = params.uri;
253
+ const at = await where(uri);
254
+ let body;
255
+ try {
256
+ const file = await open(at, constants.O_RDONLY | constants.O_NOFOLLOW);
257
+ try {
258
+ body = await file.readFile();
259
+ }
260
+ finally {
261
+ await file.close();
262
+ }
263
+ }
264
+ catch {
265
+ return gone(uri);
266
+ }
267
+ // Binary MUST be base64 and text MAY be utf-8. A NUL byte is the cheap
268
+ // test, and a host handed a PNG as utf-8 receives something that is
269
+ // not the file.
270
+ return body.includes(0)
271
+ ? { data: body.toString('base64'), encoding: 'base64' }
272
+ : { data: body.toString('utf8'), encoding: 'utf-8' };
273
+ },
274
+ resourceList: async (params) => {
275
+ const uri = params.uri;
276
+ const at = await where(uri);
277
+ try {
278
+ const found = await readdir(at, { withFileTypes: true });
279
+ const boundary = await realpath(root);
280
+ return {
281
+ entries: found.map((entry) => ({
282
+ uri: `${PREFIX}${path.relative(boundary, path.join(at, entry.name))}`,
283
+ name: entry.name,
284
+ type: entry.isDirectory() ? 'directory' : 'file',
285
+ })),
286
+ };
287
+ }
288
+ catch {
289
+ return gone(uri);
290
+ }
291
+ },
292
+ resourceResolve: async (params) => {
293
+ const uri = params.uri;
294
+ const at = await where(uri);
295
+ try {
296
+ const found = await stat(at);
297
+ return {
298
+ uri: String(uri),
299
+ type: found.isDirectory() ? 'directory' : 'file',
300
+ size: found.size,
301
+ mtime: new Date(found.mtimeMs).toISOString(),
302
+ ...(found.isDirectory() ? {} : { etag: tagOf(found.size, found.mtimeMs) }),
303
+ };
304
+ }
305
+ catch {
306
+ return gone(uri);
307
+ }
308
+ },
309
+ /*
310
+ * The one method that answers without touching the disk.
311
+ *
312
+ * `resourceRequest` is how a peer asks for access it does not have. This
313
+ * client has no way to put the question to whoever started it - it may
314
+ * be a pipe in a script - so the honest answer is that the grant is a
315
+ * flag rather than a conversation.
316
+ */
317
+ resourceRequest: async () => ({
318
+ granted: false,
319
+ reason: writable
320
+ ? 'This client grants only what --publish named.'
321
+ : 'This client published its directory read-only. Restart it with --publish-writable.',
322
+ }),
323
+ resourceWrite: async (params) => {
324
+ const input = params;
325
+ mutable();
326
+ await write(await where(input.uri), input.uri, input);
327
+ return {};
328
+ },
329
+ resourceDelete: async (params) => {
330
+ const { uri, recursive } = params;
331
+ mutable();
332
+ await rm(await where(uri, true), { recursive: recursive === true });
333
+ return {};
334
+ },
335
+ resourceMkdir: async (params) => {
336
+ mutable();
337
+ await mkdir(await where(params.uri), { recursive: true });
338
+ return {};
339
+ },
340
+ resourceMove: async (params) => {
341
+ const { source, destination } = params;
342
+ mutable();
343
+ await rename(await where(source, true), await where(destination, true));
344
+ return {};
345
+ },
346
+ resourceCopy: async (params) => {
347
+ const { source, destination } = params;
348
+ mutable();
349
+ const from = await where(source);
350
+ const to = await where(destination);
351
+ const input = await open(from, constants.O_RDONLY | constants.O_NOFOLLOW);
352
+ try {
353
+ const output = await open(to, constants.O_WRONLY | constants.O_CREAT | constants.O_NOFOLLOW);
354
+ try {
355
+ const bytes = await input.readFile();
356
+ await output.truncate(0);
357
+ await output.writeFile(bytes);
358
+ }
359
+ finally {
360
+ await output.close();
361
+ }
362
+ }
363
+ finally {
364
+ await input.close();
365
+ }
366
+ return {};
367
+ },
368
+ }),
369
+ };
370
+ }
@@ -0,0 +1,23 @@
1
+ import type { SessionSummary } from './types.js';
2
+ /**
3
+ * The status bitset, decoded once.
4
+ *
5
+ * `InputNeeded` is 24 and carries `InProgress` (8), so testing `InProgress`
6
+ * first swallows it: a session blocked on a confirmation reads as merely
7
+ * running, and the one state that wants a person is the one nobody sees.
8
+ * Order matters here and nowhere else, which is why it is one function.
9
+ */
10
+ export type Activity = 'input' | 'running' | 'error' | 'idle';
11
+ export interface Status {
12
+ activity: Activity;
13
+ archived: boolean;
14
+ read: boolean;
15
+ /** The word. Never the only carrier of the meaning. */
16
+ label: string;
17
+ /** The colour. Also never the only carrier. */
18
+ tone: 'warning' | 'accent' | 'danger' | 'muted';
19
+ /** The shape a 16-colour session, a piped log and a colourblind reader keep. */
20
+ glyph: 'bulletHalf' | 'bulletFilled' | 'cross' | 'bulletHollow';
21
+ }
22
+ export declare function decodeStatus(status: number): Status;
23
+ export declare function byUrgency(a: SessionSummary, b: SessionSummary): number;
@@ -0,0 +1,23 @@
1
+ import { SessionFlag } from './types.js';
2
+ export function decodeStatus(status) {
3
+ const archived = (status & SessionFlag.IsArchived) !== 0;
4
+ const read = (status & SessionFlag.IsRead) !== 0;
5
+ // Most specific first.
6
+ const activity = (status & SessionFlag.InputNeeded) === SessionFlag.InputNeeded ? 'input'
7
+ : (status & SessionFlag.InProgress) !== 0 ? 'running'
8
+ : (status & SessionFlag.Error) !== 0 ? 'error'
9
+ : 'idle';
10
+ const shown = {
11
+ input: { label: 'waiting on you', tone: 'warning', glyph: 'bulletHalf' },
12
+ running: { label: 'running', tone: 'accent', glyph: 'bulletFilled' },
13
+ error: { label: 'error', tone: 'danger', glyph: 'cross' },
14
+ idle: { label: 'idle', tone: 'muted', glyph: 'bulletHollow' },
15
+ };
16
+ return { activity, archived, read, ...shown[activity] };
17
+ }
18
+ /** Sessions worth answering first, then worth watching, then the rest. */
19
+ const RANK = { input: 0, running: 1, error: 2, idle: 3 };
20
+ export function byUrgency(a, b) {
21
+ const rank = RANK[decodeStatus(a.status).activity] - RANK[decodeStatus(b.status).activity];
22
+ return rank !== 0 ? rank : b.modifiedAt.localeCompare(a.modifiedAt);
23
+ }