@x-tech-laboratories/x-libs 0.2.0 → 0.4.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 (63) hide show
  1. package/README.md +23 -13
  2. package/dist/agent/client.d.ts +9 -6
  3. package/dist/agent/client.js +3 -3
  4. package/dist/agent/keys.js +31 -119
  5. package/dist/agent/long_runs.d.ts +5 -5
  6. package/dist/agent/long_runs.js +9 -10
  7. package/dist/agent/protocol.d.ts +20 -14
  8. package/dist/agent/protocol.js +34 -17
  9. package/dist/agent/session.d.ts +11 -8
  10. package/dist/agent/session.js +13 -9
  11. package/dist/agent/tasks.d.ts +17 -0
  12. package/dist/agent/tasks.js +15 -0
  13. package/dist/durations.js +18 -20
  14. package/dist/server/agents/agent.d.ts +4 -4
  15. package/dist/server/agents/connection.d.ts +8 -7
  16. package/dist/server/agents/connection.js +11 -9
  17. package/dist/server/agents/hub.d.ts +12 -12
  18. package/dist/server/agents/hub.js +14 -14
  19. package/dist/server/agents/local.d.ts +10 -7
  20. package/dist/server/agents/local.js +12 -9
  21. package/dist/server/agents/route.d.ts +1 -1
  22. package/dist/server/hash_password.js +16 -34
  23. package/dist/web/format.js +25 -29
  24. package/dist/web/menus.d.ts +0 -7
  25. package/dist/web/menus.js +2 -87
  26. package/dist/web/preact/badges.d.ts +26 -0
  27. package/dist/web/preact/badges.js +28 -0
  28. package/dist/web/preact/controls.d.ts +14 -1
  29. package/dist/web/preact/controls.js +14 -7
  30. package/dist/web/preact/dialog.d.ts +25 -1
  31. package/dist/web/preact/dialog.js +56 -3
  32. package/dist/web/preact/layout.d.ts +37 -37
  33. package/dist/web/preact/layout.js +27 -25
  34. package/dist/web/preact/menus.d.ts +4 -3
  35. package/dist/web/preact/menus.js +56 -15
  36. package/dist/web/preact/shell.d.ts +27 -0
  37. package/dist/web/preact/shell.js +63 -0
  38. package/dist/web/preact/states.d.ts +27 -0
  39. package/dist/web/preact/states.js +48 -0
  40. package/dist/web/preact/table.d.ts +6 -1
  41. package/dist/web/preact/table.js +8 -1
  42. package/dist/web/preact/time.d.ts +4 -0
  43. package/dist/web/preact/time.js +9 -0
  44. package/dist/web/{toasts.d.ts → preact/toasts.d.ts} +2 -1
  45. package/dist/web/preact/toasts.js +34 -0
  46. package/dist/web/states.d.ts +1 -8
  47. package/dist/web/states.js +6 -43
  48. package/dist/web/tables.d.ts +4 -14
  49. package/dist/web/tables.js +1 -27
  50. package/dist/web/theme.d.ts +1 -1
  51. package/dist/web/theme.js +4 -1
  52. package/package.json +9 -2
  53. package/dist/web/badges.d.ts +0 -10
  54. package/dist/web/badges.js +0 -30
  55. package/dist/web/controls.d.ts +0 -6
  56. package/dist/web/controls.js +0 -55
  57. package/dist/web/dialogs.d.ts +0 -18
  58. package/dist/web/dialogs.js +0 -49
  59. package/dist/web/layout.d.ts +0 -28
  60. package/dist/web/layout.js +0 -53
  61. package/dist/web/shell.d.ts +0 -7
  62. package/dist/web/shell.js +0 -51
  63. package/dist/web/toasts.js +0 -22
@@ -1,9 +1,8 @@
1
1
  // One connection to the server: says hello, with its signature of the server's challenge when it logs in with a key,
2
2
  // answers each request, and sends streamed output only as far as the server's credit allows. Closing it stops the
3
3
  // streams it was sending; long runs carry on.
4
- import { run_command, stream_command } from "./commands.js";
5
4
  import { sign_challenge } from "./keys.js";
6
- import { encode_chunk, encode_message, is_server_message, parse_server_message, protocol_version, } from "./protocol.js";
5
+ import { encode_chunk, encode_message, is_server_message, parse_server_message, } from "./protocol.js";
7
6
  const socket_open = 1;
8
7
  // ── Output ───────────────────────────────────────────────────────────────────
9
8
  // Sends a stream's stdout as binary frames within the credit granted, then its end once all of it has gone.
@@ -90,14 +89,14 @@ export class AgentSession {
90
89
  }
91
90
  say_hello(signature) {
92
91
  this.greeted = true;
93
- const hello = { type: "hello", protocol: protocol_version, host: this.options.host, agent_version: this.options.agent_version, runs: this.runs.list() };
92
+ const hello = { type: "hello", protocol: this.options.app.protocol_version, host: this.options.host, agent_version: this.options.agent_version, runs: this.runs.list() };
94
93
  this.send(signature === undefined ? hello : { ...hello, signature });
95
94
  this.options.on_hello?.(this);
96
95
  }
97
96
  // Handles one text frame from the server; anything that is not a message is ignored.
98
97
  receive(text) {
99
98
  this.last_heard = Date.now();
100
- const message = parse_server_message(text);
99
+ const message = parse_server_message(text, this.options.app);
101
100
  if (message === null)
102
101
  return;
103
102
  if (is_server_message(message))
@@ -118,7 +117,7 @@ export class AgentSession {
118
117
  // ── Sending ──────────────────────────────────────────────────────────────
119
118
  send(message) {
120
119
  if (!this.closed && this.socket.readyState === socket_open)
121
- this.socket.send(encode_message(message));
120
+ this.socket.send(encode_message(message, this.options.app));
122
121
  }
123
122
  send_chunk(id, data) {
124
123
  if (!this.closed && this.socket.readyState === socket_open)
@@ -138,7 +137,7 @@ export class AgentSession {
138
137
  return;
139
138
  case "challenge":
140
139
  if (this.options.key && !this.greeted)
141
- this.say_hello(sign_challenge(this.options.key, this.options.app, this.options.host, message.nonce));
140
+ this.say_hello(sign_challenge(this.options.key, this.options.app.name, this.options.host, message.nonce));
142
141
  return;
143
142
  case "credit":
144
143
  this.pumps.get(message.id)?.grant(message.bytes);
@@ -147,7 +146,7 @@ export class AgentSession {
147
146
  this.pumps.get(message.id)?.cancel();
148
147
  return;
149
148
  case "stream":
150
- this.start_pump(message.id, () => stream_command(message.command, { stdin: message.stdin }), message.window);
149
+ this.start_pump(message.id, () => this.open_stream(message.task, message.stdin), message.window);
151
150
  return;
152
151
  case "attach":
153
152
  this.start_pump(message.id, () => this.runs.tail(message.run_id, message.offset), message.window);
@@ -192,12 +191,17 @@ export class AgentSession {
192
191
  this.send({ type: "failed", id, error: error.message });
193
192
  }
194
193
  }
194
+ open_stream(task, stdin) {
195
+ if (this.options.runner.stream === undefined)
196
+ throw new Error("This agent does not stream its tasks.");
197
+ return this.options.runner.stream(task, { stdin });
198
+ }
195
199
  async value_for(message) {
196
200
  switch (message.type) {
197
201
  case "run":
198
- return run_command(message.command, { timeout_in_seconds: message.timeout_in_seconds, stdin: message.stdin });
202
+ return this.options.runner.run(message.task, { timeout_in_seconds: message.timeout_in_seconds, stdin: message.stdin });
199
203
  case "start":
200
- return this.runs.start(message.run_id, message.command, message.stdin);
204
+ return this.runs.start(message.run_id, message.task, message.stdin);
201
205
  case "poll":
202
206
  return this.runs.poll(message.run_id, message.offset);
203
207
  case "stop":
@@ -0,0 +1,17 @@
1
+ import { type CommandStream } from "./commands.ts";
2
+ import type { RunResult } from "./protocol.ts";
3
+ export interface LongRunLaunch {
4
+ program: string;
5
+ args: string[];
6
+ }
7
+ export interface TaskRunner<Task> {
8
+ run(task: Task, options: {
9
+ timeout_in_seconds: number;
10
+ stdin?: string | null;
11
+ }): Promise<RunResult>;
12
+ stream?(task: Task, options: {
13
+ stdin?: string | null;
14
+ }): CommandStream;
15
+ launch(task: Task, folder: string, has_stdin: boolean): LongRunLaunch;
16
+ }
17
+ export declare function shell_runner(process_name: string): TaskRunner<string>;
@@ -0,0 +1,15 @@
1
+ // How an agent carries out the app's tasks: to their end, as a stream, or as a long run's own process. Shell tasks, a
2
+ // command line run by sh, come built in; an app with typed operations gives its own.
3
+ // ── Imports ──────────────────────────────────────────────────────────────────
4
+ import { run_command, stream_command } from "./commands.js";
5
+ // ── Shell ────────────────────────────────────────────────────────────────────
6
+ // The outer shell runs the command with its output in the run's folder, then writes the exit code in one rename.
7
+ const wrapper = 'sh -c "$2" > "$1/out" 2>&1; echo $? > "$1/exit.part"; mv -f "$1/exit.part" "$1/exit"';
8
+ // Runs each task as a command line under sh; `process_name`, such as "x-backups-run", is what ps shows for a long run.
9
+ export function shell_runner(process_name) {
10
+ return {
11
+ run: (command, options) => run_command(command, options),
12
+ stream: (command, options) => stream_command(command, options),
13
+ launch: (command, folder) => ({ program: "sh", args: ["-c", wrapper, process_name, folder, command] }),
14
+ };
15
+ }
package/dist/durations.js CHANGED
@@ -1,36 +1,34 @@
1
1
  // Reads durations such as "2h30m" from settings and writes them back in words, for the server and the agent alike.
2
- const unit_seconds = { w: 604800, d: 86400, h: 3600, m: 60, s: 1 };
3
- const unit_names = [
4
- [604800, "week"], [86400, "day"], [3600, "hour"], [60, "minute"], [1, "second"],
5
- ];
6
- // A duration in seconds; `key` names the setting in the error.
2
+ import parse from "parse-duration";
3
+ const unit_names = [["weeks", 604800], ["days", 86400], ["hours", 3600], ["minutes", 60], ["seconds", 1]];
4
+ const words = new Intl.DurationFormat("en", { style: "long" });
5
+ // A duration in whole seconds; `key` names the setting in the error.
7
6
  export function parse_duration(value, key) {
8
7
  if (typeof value === "number" && Number.isFinite(value) && value >= 0)
9
8
  return Math.floor(value);
10
- const text = String(value).trim().toLowerCase();
11
- if (/^\d+$/.test(text))
12
- return Number(text);
13
- if (!/^(\d+[wdhms])+$/.test(text))
9
+ const text = String(value).trim();
10
+ // A bare number is seconds, where parse-duration would read milliseconds.
11
+ const seconds = /^\d+$/.test(text) ? Number(text) : parse(text, "s");
12
+ if (seconds === null || seconds === undefined || !Number.isFinite(seconds) || seconds < 0) {
14
13
  throw new Error(`\`${key}\` must be a duration such as "1w", "7d", "2h30m" or "60s", not ${JSON.stringify(value)}`);
15
- let seconds = 0;
16
- for (const [, count, unit] of text.matchAll(/(\d+)([wdhms])/g))
17
- seconds += Number(count) * unit_seconds[unit];
18
- return seconds;
14
+ }
15
+ return Math.floor(seconds);
19
16
  }
20
- // The two largest whole units, such as "1 day 2 hours".
17
+ // The two largest whole units, such as "1 day, 2 hours".
21
18
  export function format_duration(seconds) {
22
- if (seconds <= 0)
19
+ if (seconds < 1)
23
20
  return "0 seconds";
24
- const parts = [];
21
+ const parts = {};
25
22
  let remaining = Math.floor(seconds);
26
- for (const [size, name] of unit_names) {
23
+ for (const [name, size] of unit_names) {
24
+ if (Object.keys(parts).length === 2)
25
+ break;
27
26
  if (remaining < size)
28
27
  continue;
29
- const count = Math.floor(remaining / size);
28
+ parts[name] = Math.floor(remaining / size);
30
29
  remaining %= size;
31
- parts.push(`${count} ${name}${count === 1 ? "" : "s"}`);
32
30
  }
33
- return parts.slice(0, 2).join(" ");
31
+ return words.format(parts);
34
32
  }
35
33
  export function format_age(seconds) {
36
34
  return seconds < 0 ? "never" : `${format_duration(seconds)} ago`;
@@ -14,10 +14,10 @@ export declare class AgentError extends Error {
14
14
  constructor(message: string, unreachable?: boolean);
15
15
  }
16
16
  export declare function ended_stream(stderr: string): CommandStream;
17
- export interface Agent {
18
- run(command: string, options: AgentRunOptions): Promise<RunResult>;
19
- stream(command: string, options?: AgentStreamOptions): CommandStream;
20
- start(run_id: string, command: string, options?: AgentStreamOptions): Promise<LongRun>;
17
+ export interface Agent<Task> {
18
+ run(task: Task, options: AgentRunOptions): Promise<RunResult>;
19
+ stream(task: Task, options?: AgentStreamOptions): CommandStream;
20
+ start(run_id: string, task: Task, options?: AgentStreamOptions): Promise<LongRun>;
21
21
  poll(run_id: string, offset: number): Promise<LongRunPoll>;
22
22
  attach(run_id: string, offset: number): CommandStream;
23
23
  stop(run_id: string): Promise<LongRun>;
@@ -1,4 +1,4 @@
1
- import { type AgentMessage, type AppMessage, type LongRun, type LongRunPoll, type RunResult, type ServerMessage } from "../../agent/protocol.ts";
1
+ import { type AgentApp, type AgentMessage, type AppMessage, type LongRun, type LongRunPoll, type RunResult, type ServerMessage } from "../../agent/protocol.ts";
2
2
  import { type Agent, type AgentRunOptions, type AgentStreamOptions, type CommandStream } from "./agent.ts";
3
3
  export interface ServerSocket {
4
4
  readonly readyState: number;
@@ -16,12 +16,13 @@ export interface ConnectionOptions {
16
16
  export type Hello = Omit<Extract<AgentMessage, {
17
17
  type: "hello";
18
18
  }>, "signature">;
19
- export declare class AgentConnection implements Agent {
19
+ export declare class AgentConnection<Task> implements Agent<Task> {
20
20
  readonly host: string;
21
21
  readonly hello: Hello;
22
22
  readonly connected_epoch: number;
23
23
  on_message: ((message: AppMessage) => void) | null;
24
24
  private readonly socket;
25
+ private readonly app;
25
26
  private readonly options;
26
27
  private readonly pending;
27
28
  private readonly streams;
@@ -29,18 +30,18 @@ export declare class AgentConnection implements Agent {
29
30
  private last_heard;
30
31
  private readonly heartbeat;
31
32
  private closed_reason;
32
- constructor(socket: ServerSocket, host: string, hello: Hello, options: ConnectionOptions);
33
+ constructor(socket: ServerSocket, app: AgentApp<Task>, host: string, hello: Hello, options: ConnectionOptions);
33
34
  get is_open(): boolean;
34
- run(command: string, options: AgentRunOptions): Promise<RunResult>;
35
- stream(command: string, options?: AgentStreamOptions): CommandStream;
36
- start(run_id: string, command: string, options?: AgentStreamOptions): Promise<LongRun>;
35
+ run(task: Task, options: AgentRunOptions): Promise<RunResult>;
36
+ stream(task: Task, options?: AgentStreamOptions): CommandStream;
37
+ start(run_id: string, task: Task, options?: AgentStreamOptions): Promise<LongRun>;
37
38
  poll(run_id: string, offset: number): Promise<LongRunPoll>;
38
39
  attach(run_id: string, offset: number): CommandStream;
39
40
  stop(run_id: string): Promise<LongRun>;
40
41
  forget(run_id: string): Promise<void>;
41
42
  runs(): Promise<LongRun[]>;
42
43
  request(type: string, body?: Record<string, unknown>): Promise<unknown>;
43
- send(message: ServerMessage | AppMessage): void;
44
+ send(message: ServerMessage<Task> | AppMessage): void;
44
45
  receive(data: Buffer, is_binary: boolean): void;
45
46
  closed(reason: string): void;
46
47
  close(code: number, reason: string): void;
@@ -82,6 +82,7 @@ export class AgentConnection {
82
82
  // Takes the app's own messages from the agent that are not replies.
83
83
  on_message = null;
84
84
  socket;
85
+ app;
85
86
  options;
86
87
  pending = new Map();
87
88
  streams = new Map();
@@ -89,8 +90,9 @@ export class AgentConnection {
89
90
  last_heard = Date.now();
90
91
  heartbeat;
91
92
  closed_reason = null;
92
- constructor(socket, host, hello, options) {
93
+ constructor(socket, app, host, hello, options) {
93
94
  this.socket = socket;
95
+ this.app = app;
94
96
  this.host = host;
95
97
  this.hello = hello;
96
98
  this.options = options;
@@ -101,20 +103,20 @@ export class AgentConnection {
101
103
  return this.closed_reason === null;
102
104
  }
103
105
  // ── Agent ────────────────────────────────────────────────────────────────
104
- async run(command, options) {
106
+ async run(task, options) {
105
107
  try {
106
108
  const timeout = options.timeout_in_seconds * 1000 + run_reply_slack_in_milliseconds;
107
- return await this.send_request("run", { command, stdin: options.stdin ?? null, timeout_in_seconds: options.timeout_in_seconds }, timeout);
109
+ return await this.send_request("run", { task, stdin: options.stdin ?? null, timeout_in_seconds: options.timeout_in_seconds }, timeout);
108
110
  }
109
111
  catch (error) {
110
112
  return { exit_code: -1, stdout: "", stderr: error.message };
111
113
  }
112
114
  }
113
- stream(command, options = {}) {
114
- return this.open_stream((id, window) => ({ type: "stream", id, command, stdin: options.stdin ?? null, window }));
115
+ stream(task, options = {}) {
116
+ return this.open_stream((id, window) => ({ type: "stream", id, task, stdin: options.stdin ?? null, window }));
115
117
  }
116
- start(run_id, command, options = {}) {
117
- return this.send_request("start", { run_id, command, stdin: options.stdin ?? null });
118
+ start(run_id, task, options = {}) {
119
+ return this.send_request("start", { run_id, task, stdin: options.stdin ?? null });
118
120
  }
119
121
  poll(run_id, offset) {
120
122
  return this.send_request("poll", { run_id, offset });
@@ -137,7 +139,7 @@ export class AgentConnection {
137
139
  // ── Socket ───────────────────────────────────────────────────────────────
138
140
  send(message) {
139
141
  if (this.is_open && this.socket.readyState === socket_open)
140
- this.socket.send(encode_message(message));
142
+ this.socket.send(encode_message(message, this.app));
141
143
  }
142
144
  // Handles one frame from the agent after its hello.
143
145
  receive(data, is_binary) {
@@ -148,7 +150,7 @@ export class AgentConnection {
148
150
  this.streams.get(chunk.id)?.receive(chunk.data);
149
151
  return;
150
152
  }
151
- const message = parse_agent_message(data.toString("utf8"));
153
+ const message = parse_agent_message(data.toString("utf8"), this.app);
152
154
  if (message === null)
153
155
  return;
154
156
  if (!is_agent_message(message)) {
@@ -1,20 +1,20 @@
1
1
  import { EventEmitter } from "node:events";
2
2
  import type { IncomingHttpHeaders } from "node:http";
3
- import { type AppMessage, type LongRun, type LongRunPoll, type RunResult } from "../../agent/protocol.ts";
3
+ import { type AgentApp, type AppMessage, type LongRun, type LongRunPoll, type RunResult } from "../../agent/protocol.ts";
4
4
  import { type Agent, type AgentRunOptions, type AgentStreamOptions, type CommandStream } from "./agent.ts";
5
5
  import { AgentConnection, type ConnectionOptions, type ServerSocket } from "./connection.ts";
6
6
  export interface AgentTokenLookup {
7
7
  host_for(token: string): string | null;
8
8
  seen?(host: string): void;
9
9
  }
10
- export interface HubOptions {
11
- app: string;
10
+ export interface HubOptions<Task> {
11
+ app: AgentApp<Task>;
12
12
  local?: {
13
13
  host: string;
14
- agent: Agent;
14
+ agent: Agent<Task>;
15
15
  };
16
16
  tokens?: AgentTokenLookup;
17
- on_message?: (host: string, message: AppMessage, connection: AgentConnection) => void;
17
+ on_message?: (host: string, message: AppMessage, connection: AgentConnection<Task>) => void;
18
18
  hello_timeout_in_milliseconds?: number;
19
19
  connection?: Partial<ConnectionOptions>;
20
20
  }
@@ -45,8 +45,8 @@ export declare const close_codes: {
45
45
  readonly no_hello: 4005;
46
46
  readonly bad_key: 4006;
47
47
  };
48
- export declare class AgentHub extends EventEmitter<HubEvents> {
49
- readonly app: string;
48
+ export declare class AgentHub<Task> extends EventEmitter<HubEvents> {
49
+ readonly app: AgentApp<Task>;
50
50
  private readonly local;
51
51
  private readonly tokens;
52
52
  private readonly on_message;
@@ -55,7 +55,7 @@ export declare class AgentHub extends EventEmitter<HubEvents> {
55
55
  private readonly connections;
56
56
  private readonly logins;
57
57
  private agent_keys;
58
- constructor(options: HubOptions);
58
+ constructor(options: HubOptions<Task>);
59
59
  set_local_host(host: string): void;
60
60
  set_agent_keys(lines: Map<string, string>): void;
61
61
  has_agent_key(host: string): boolean;
@@ -70,10 +70,10 @@ export declare class AgentHub extends EventEmitter<HubEvents> {
70
70
  is_connected(host: string): boolean;
71
71
  status(host: string): AgentStatus | null;
72
72
  statuses(): AgentStatus[];
73
- agent(host: string): Agent | null;
74
- run(host: string, command: string, options: AgentRunOptions): Promise<RunResult>;
75
- stream(host: string, command: string, options?: AgentStreamOptions): CommandStream;
76
- start(host: string, run_id: string, command: string, options?: AgentStreamOptions): Promise<LongRun>;
73
+ agent(host: string): Agent<Task> | null;
74
+ run(host: string, task: Task, options: AgentRunOptions): Promise<RunResult>;
75
+ stream(host: string, task: Task, options?: AgentStreamOptions): CommandStream;
76
+ start(host: string, run_id: string, task: Task, options?: AgentStreamOptions): Promise<LongRun>;
77
77
  poll(host: string, run_id: string, offset: number): Promise<LongRunPoll>;
78
78
  attach(host: string, run_id: string, offset: number): CommandStream;
79
79
  stop(host: string, run_id: string): Promise<LongRun>;
@@ -5,7 +5,7 @@
5
5
  import { randomBytes } from "node:crypto";
6
6
  import { EventEmitter } from "node:events";
7
7
  import { parse_public_key, verify_challenge } from "../../agent/keys.js";
8
- import { encode_message, is_agent_message, key_host_header, parse_agent_message, protocol_version, } from "../../agent/protocol.js";
8
+ import { encode_message, is_agent_message, key_host_header, parse_agent_message, } from "../../agent/protocol.js";
9
9
  import { AgentError, ended_stream } from "./agent.js";
10
10
  import { AgentConnection } from "./connection.js";
11
11
  // Close codes the hub hangs up with.
@@ -79,7 +79,7 @@ export class AgentHub extends EventEmitter {
79
79
  }
80
80
  // Who an agent's upgrade request says it is: the host it names to log in with that host's key, or a token's host.
81
81
  admit(headers) {
82
- const header = key_host_header(this.app);
82
+ const header = key_host_header(this.app.name);
83
83
  const named = headers[header];
84
84
  if (typeof named === "string" && named !== "") {
85
85
  if (named === this.local?.host)
@@ -105,19 +105,19 @@ export class AgentHub extends EventEmitter {
105
105
  const hello_timer = setTimeout(() => socket.close(close_codes.no_hello, "No hello came."), this.hello_timeout);
106
106
  const nonce = login === "key" ? randomBytes(32).toString("base64url") : null;
107
107
  if (nonce !== null)
108
- socket.send(encode_message({ type: "challenge", nonce }));
108
+ socket.send(encode_message({ type: "challenge", nonce }, this.app));
109
109
  socket.on("message", (raw, is_binary) => {
110
110
  const data = as_buffer(raw);
111
111
  if (connection !== null) {
112
112
  connection.receive(data, is_binary);
113
113
  return;
114
114
  }
115
- const hello = is_binary ? null : parse_agent_message(data.toString("utf8"));
115
+ const hello = is_binary ? null : parse_agent_message(data.toString("utf8"), this.app);
116
116
  if (hello === null || !is_agent_message(hello) || hello.type !== "hello")
117
117
  return;
118
118
  clearTimeout(hello_timer);
119
- if (hello.protocol !== protocol_version) {
120
- socket.close(close_codes.protocol, `This server speaks agent protocol ${protocol_version}, not ${hello.protocol}.`);
119
+ if (hello.protocol !== this.app.protocol_version) {
120
+ socket.close(close_codes.protocol, `This server speaks agent protocol ${this.app.protocol_version}, not ${hello.protocol}.`);
121
121
  return;
122
122
  }
123
123
  const { signature, ...greeting } = hello;
@@ -126,7 +126,7 @@ export class AgentHub extends EventEmitter {
126
126
  return;
127
127
  }
128
128
  this.connections.get(host)?.close(close_codes.replaced, "A newer connection from the same host replaced this one.");
129
- const current = new AgentConnection(socket, host, greeting, this.connection_options);
129
+ const current = new AgentConnection(socket, this.app, host, greeting, this.connection_options);
130
130
  current.on_message = (message) => this.on_message?.(host, message, current);
131
131
  connection = current;
132
132
  this.connections.set(host, current);
@@ -149,7 +149,7 @@ export class AgentHub extends EventEmitter {
149
149
  }
150
150
  proves_key(host, nonce, signature) {
151
151
  const declared = this.agent_keys.get(host);
152
- return declared !== undefined && typeof signature === "string" && verify_challenge(declared.key, this.app, host, nonce, signature);
152
+ return declared !== undefined && typeof signature === "string" && verify_challenge(declared.key, this.app.name, host, nonce, signature);
153
153
  }
154
154
  disconnect(host, code = 1000, reason = "The server hung up.") {
155
155
  this.connections.get(host)?.close(code, reason);
@@ -181,15 +181,15 @@ export class AgentHub extends EventEmitter {
181
181
  }
182
182
  // ── Commands ─────────────────────────────────────────────────────────────
183
183
  // A run on a host with no agent answers with exit code -1 rather than rejecting, as a command that failed does.
184
- run(host, command, options) {
184
+ run(host, task, options) {
185
185
  const agent = this.agent(host);
186
- return agent === null ? Promise.resolve({ exit_code: -1, stdout: "", stderr: not_connected(host) }) : agent.run(command, options);
186
+ return agent === null ? Promise.resolve({ exit_code: -1, stdout: "", stderr: not_connected(host) }) : agent.run(task, options);
187
187
  }
188
- stream(host, command, options = {}) {
189
- return this.agent(host)?.stream(command, options) ?? ended_stream(not_connected(host));
188
+ stream(host, task, options = {}) {
189
+ return this.agent(host)?.stream(task, options) ?? ended_stream(not_connected(host));
190
190
  }
191
- start(host, run_id, command, options = {}) {
192
- return this.require(host).then((agent) => agent.start(run_id, command, options));
191
+ start(host, run_id, task, options = {}) {
192
+ return this.require(host).then((agent) => agent.start(run_id, task, options));
193
193
  }
194
194
  poll(host, run_id, offset) {
195
195
  return this.require(host).then((agent) => agent.poll(run_id, offset));
@@ -1,17 +1,20 @@
1
1
  import { LongRuns, type LongRunOptions } from "../../agent/long_runs.ts";
2
2
  import type { AppRequests } from "../../agent/session.ts";
3
+ import type { TaskRunner } from "../../agent/tasks.ts";
3
4
  import { type Agent, type AgentRunOptions, type AgentStreamOptions, type CommandStream, type LongRun, type LongRunPoll, type RunResult } from "./agent.ts";
4
- export interface LocalAgentOptions {
5
+ export interface LocalAgentOptions<Task> {
6
+ runner: TaskRunner<Task>;
5
7
  long_runs?: LongRunOptions;
6
8
  requests?: AppRequests;
7
9
  }
8
- export declare class LocalAgent implements Agent {
9
- readonly long_runs: LongRuns;
10
+ export declare class LocalAgent<Task> implements Agent<Task> {
11
+ readonly long_runs: LongRuns<Task>;
12
+ private readonly runner;
10
13
  private readonly requests;
11
- constructor(state_directory: string, options?: LocalAgentOptions);
12
- run(command: string, options: AgentRunOptions): Promise<RunResult>;
13
- stream(command: string, options?: AgentStreamOptions): CommandStream;
14
- start(run_id: string, command: string, options?: AgentStreamOptions): Promise<LongRun>;
14
+ constructor(state_directory: string, options: LocalAgentOptions<Task>);
15
+ run(task: Task, options: AgentRunOptions): Promise<RunResult>;
16
+ stream(task: Task, options?: AgentStreamOptions): CommandStream;
17
+ start(run_id: string, task: Task, options?: AgentStreamOptions): Promise<LongRun>;
15
18
  poll(run_id: string, offset: number): Promise<LongRunPoll>;
16
19
  attach(run_id: string, offset: number): CommandStream;
17
20
  stop(run_id: string): Promise<LongRun>;
@@ -1,26 +1,29 @@
1
1
  // The agent built into the server for its own machine. It runs commands itself with no socket between, through the
2
2
  // same code a remote agent uses, so a command behaves the same on every host.
3
3
  // ── Imports ──────────────────────────────────────────────────────────────────
4
- import { run_command, stream_command } from "../../agent/commands.js";
5
4
  import { LongRuns } from "../../agent/long_runs.js";
6
5
  import { AgentError, ended_stream, } from "./agent.js";
7
6
  // ── Local agent ──────────────────────────────────────────────────────────────
8
7
  export class LocalAgent {
9
8
  long_runs;
9
+ runner;
10
10
  requests;
11
11
  // Long runs keep their output under the state directory, so they outlive a restart of the server.
12
- constructor(state_directory, options = {}) {
13
- this.long_runs = new LongRuns(state_directory, options.long_runs);
12
+ constructor(state_directory, options) {
13
+ this.runner = options.runner;
14
+ this.long_runs = new LongRuns(state_directory, options.runner, options.long_runs);
14
15
  this.requests = options.requests ?? {};
15
16
  }
16
- run(command, options) {
17
- return run_command(command, options);
17
+ run(task, options) {
18
+ return this.runner.run(task, options);
18
19
  }
19
- stream(command, options = {}) {
20
- return stream_command(command, options);
20
+ stream(task, options = {}) {
21
+ if (this.runner.stream === undefined)
22
+ return ended_stream("This agent does not stream its tasks.");
23
+ return this.runner.stream(task, options);
21
24
  }
22
- start(run_id, command, options = {}) {
23
- return as_agent_error(() => this.long_runs.start(run_id, command, options.stdin ?? null));
25
+ start(run_id, task, options = {}) {
26
+ return as_agent_error(() => this.long_runs.start(run_id, task, options.stdin ?? null));
24
27
  }
25
28
  poll(run_id, offset) {
26
29
  return as_agent_error(() => this.long_runs.poll(run_id, offset));
@@ -1,3 +1,3 @@
1
1
  import type { FastifyInstance } from "fastify";
2
2
  import type { AgentHub } from "./hub.ts";
3
- export declare function register_agent_route(app: FastifyInstance, hub: AgentHub, path?: string): Promise<void>;
3
+ export declare function register_agent_route<Task>(app: FastifyInstance, hub: AgentHub<Task>, path?: string): Promise<void>;
@@ -3,46 +3,28 @@
3
3
  // ── Imports ──────────────────────────────────────────────────────────────────
4
4
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
5
5
  import { text } from "node:stream/consumers";
6
+ import password from "@inquirer/password";
6
7
  import { account_name_pattern, hash_password, set_account } from "./passwords.js";
7
8
  // ── Asking ───────────────────────────────────────────────────────────────────
8
- // Reads one line without echoing it.
9
- function ask_hidden(question) {
10
- return new Promise((resolve) => {
11
- process.stderr.write(question);
12
- process.stdin.setRawMode(true);
13
- process.stdin.setEncoding("utf8");
14
- let answer = "";
15
- const on_data = (chunk) => {
16
- for (const character of chunk) {
17
- if (character === "\r" || character === "\n") {
18
- process.stdin.setRawMode(false);
19
- process.stdin.pause();
20
- process.stdin.off("data", on_data);
21
- process.stderr.write("\n");
22
- resolve(answer);
23
- return;
24
- }
25
- if (character === "\u0003")
26
- process.exit(130);
27
- if (character === "\u007f")
28
- answer = answer.slice(0, -1);
29
- else
30
- answer += character;
31
- }
32
- };
33
- process.stdin.on("data", on_data);
34
- process.stdin.resume();
35
- });
36
- }
9
+ // Asks twice at a terminal without echoing, on stderr so stdout stays clean; reads one line from a pipe.
37
10
  async function read_password() {
38
11
  if (!process.stdin.isTTY)
39
12
  return (await text(process.stdin)).replace(/\r?\n$/, "");
40
- const password = await ask_hidden("Password: ");
41
- if (password !== (await ask_hidden("Again: "))) {
42
- console.error("The passwords differ.");
43
- process.exit(1);
13
+ const ask = (message) => password({ message, mask: false }, { output: process.stderr });
14
+ try {
15
+ const first = await ask("Password:");
16
+ if (first !== (await ask("Again:"))) {
17
+ console.error("The passwords differ.");
18
+ process.exit(1);
19
+ }
20
+ return first;
21
+ }
22
+ catch (error) {
23
+ // Ctrl-C at the prompt ends the command as a shell would.
24
+ if (error.name === "ExitPromptError")
25
+ process.exit(130);
26
+ throw error;
44
27
  }
45
- return password;
46
28
  }
47
29
  // ── Command ──────────────────────────────────────────────────────────────────
48
30
  // Runs the command with `<name> [file]` from argv; `variable` is the environment variable the app reads the file from.