@messenger-agent/client 0.24.0-alpha.2

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,184 @@
1
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
2
+ import { dirname } from "node:path";
3
+ import { compareSemverVersions, currentPackageVersion, isPrereleaseVersion, resolveChannelPackageVersion, } from "./runtime.js";
4
+ const activeMaintenanceStatuses = new Set(["scheduled", "waiting", "running", "restarting", "stopping"]);
5
+ export class AutoUpgradeScheduler {
6
+ options;
7
+ state;
8
+ timer;
9
+ checkIntervalMs;
10
+ pendingRetryMs;
11
+ now;
12
+ getCurrentVersion;
13
+ getLatestVersion;
14
+ getChannel;
15
+ constructor(options) {
16
+ this.options = options;
17
+ this.checkIntervalMs = options.checkIntervalMs ?? 2 * 60 * 60 * 1000;
18
+ this.pendingRetryMs = options.pendingRetryMs ?? 30_000;
19
+ this.now = options.now ?? Date.now;
20
+ this.getCurrentVersion = options.getCurrentVersion ?? currentPackageVersion;
21
+ this.getLatestVersion = options.getLatestVersion ?? resolveChannelPackageVersion;
22
+ this.getChannel = options.getChannel ?? (() => Promise.resolve("latest"));
23
+ }
24
+ async start() {
25
+ this.state = await this.readState();
26
+ if (this.state?.status === "pending" || this.state?.status === "scheduled") {
27
+ this.arm(this.pendingRetryMs);
28
+ return;
29
+ }
30
+ const lastCheckedAt = this.state?.lastCheckedAt ? Date.parse(this.state.lastCheckedAt) : undefined;
31
+ const untilNextCheck = lastCheckedAt === undefined ? this.checkIntervalMs : this.checkIntervalMs - (this.now() - lastCheckedAt);
32
+ this.arm(Math.max(0, untilNextCheck));
33
+ }
34
+ stop() {
35
+ if (this.timer)
36
+ clearTimeout(this.timer);
37
+ this.timer = undefined;
38
+ }
39
+ getState() {
40
+ return this.state ? structuredClone(this.state) : undefined;
41
+ }
42
+ async checkNow() {
43
+ if (this.timer)
44
+ clearTimeout(this.timer);
45
+ this.timer = undefined;
46
+ await this.tick();
47
+ }
48
+ arm(delayMs) {
49
+ if (this.timer)
50
+ clearTimeout(this.timer);
51
+ this.timer = setTimeout(() => void this.tick(), delayMs);
52
+ this.timer.unref();
53
+ }
54
+ async tick() {
55
+ this.timer = undefined;
56
+ try {
57
+ const channel = await this.getChannel();
58
+ const currentVersion = await this.getCurrentVersion();
59
+ let latestVersion = this.state?.status === "pending" || this.state?.status === "scheduled" ? this.state.latestVersion : undefined;
60
+ let lastCheckedAt = this.state?.lastCheckedAt;
61
+ if (!latestVersion) {
62
+ latestVersion = await this.getLatestVersion(channel);
63
+ lastCheckedAt = this.isoNow();
64
+ }
65
+ if (compareSemverVersions(latestVersion, currentVersion) <= 0) {
66
+ await this.updateState({
67
+ status: "up-to-date",
68
+ currentVersion,
69
+ channel,
70
+ latestVersion,
71
+ lastCheckedAt,
72
+ message: `Runtime ${currentVersion} is up to date on the ${channel} channel`,
73
+ });
74
+ this.arm(this.checkIntervalMs);
75
+ return;
76
+ }
77
+ if (channel === "latest" && isPrereleaseVersion(currentVersion) && !isPrereleaseVersion(latestVersion)) {
78
+ await this.updateState({
79
+ status: "up-to-date",
80
+ currentVersion,
81
+ channel,
82
+ latestVersion,
83
+ lastCheckedAt,
84
+ message: `Runtime ${currentVersion} is a pre-release ahead of the latest stable ${latestVersion}`,
85
+ });
86
+ this.arm(this.checkIntervalMs);
87
+ return;
88
+ }
89
+ const task = this.options.maintenance.getTask();
90
+ if (isMatchingAutomaticUpgrade(task, latestVersion)) {
91
+ await this.updateState({
92
+ status: "scheduled",
93
+ currentVersion,
94
+ channel,
95
+ latestVersion,
96
+ lastCheckedAt,
97
+ message: `Waiting to upgrade to ${latestVersion}`,
98
+ });
99
+ this.arm(this.pendingRetryMs);
100
+ return;
101
+ }
102
+ if (task && activeMaintenanceStatuses.has(task.status)) {
103
+ await this.updateState({
104
+ status: "pending",
105
+ currentVersion,
106
+ channel,
107
+ latestVersion,
108
+ lastCheckedAt,
109
+ message: `Waiting for maintenance task ${task.id}`,
110
+ });
111
+ this.arm(this.pendingRetryMs);
112
+ return;
113
+ }
114
+ if (task?.source === "automatic" && (task.status === "failed" || task.status === "cancelled")) {
115
+ await this.updateState({
116
+ status: "failed",
117
+ currentVersion,
118
+ channel,
119
+ latestVersion,
120
+ lastCheckedAt,
121
+ message: task.message ??
122
+ (task.status === "cancelled"
123
+ ? `Automatic upgrade to ${latestVersion} was cancelled`
124
+ : `Automatic upgrade to ${latestVersion} failed`),
125
+ });
126
+ this.arm(this.checkIntervalMs);
127
+ return;
128
+ }
129
+ await this.options.maintenance.schedule({
130
+ operation: { type: "upgrade", version: latestVersion },
131
+ delaySeconds: 1,
132
+ allowWaiting: true,
133
+ source: "automatic",
134
+ });
135
+ await this.updateState({
136
+ status: "scheduled",
137
+ currentVersion,
138
+ channel,
139
+ latestVersion,
140
+ lastCheckedAt,
141
+ message: `Waiting to upgrade to ${latestVersion}`,
142
+ });
143
+ this.arm(this.pendingRetryMs);
144
+ }
145
+ catch (err) {
146
+ const currentVersion = await this.getCurrentVersion().catch(() => "unknown");
147
+ await this.updateState({
148
+ status: "failed",
149
+ currentVersion,
150
+ channel: this.state?.channel ?? "latest",
151
+ latestVersion: this.state?.latestVersion,
152
+ lastCheckedAt: this.isoNow(),
153
+ message: err instanceof Error ? err.message : String(err),
154
+ });
155
+ this.arm(this.checkIntervalMs);
156
+ }
157
+ }
158
+ async updateState(state) {
159
+ this.state = { ...state, updatedAt: this.isoNow() };
160
+ await mkdir(dirname(this.options.statePath), { recursive: true, mode: 0o700 });
161
+ const temporaryPath = `${this.options.statePath}.tmp`;
162
+ await writeFile(temporaryPath, `${JSON.stringify(this.state, null, 2)}\n`, { mode: 0o600 });
163
+ await rename(temporaryPath, this.options.statePath);
164
+ }
165
+ async readState() {
166
+ try {
167
+ return JSON.parse(await readFile(this.options.statePath, "utf8"));
168
+ }
169
+ catch (err) {
170
+ if (err.code === "ENOENT")
171
+ return undefined;
172
+ throw err;
173
+ }
174
+ }
175
+ isoNow() {
176
+ return new Date(this.now()).toISOString();
177
+ }
178
+ }
179
+ function isMatchingAutomaticUpgrade(task, version) {
180
+ return (task?.source === "automatic" &&
181
+ activeMaintenanceStatuses.has(task.status) &&
182
+ task.operation.type === "upgrade" &&
183
+ task.operation.version === version);
184
+ }
@@ -0,0 +1,22 @@
1
+ import { type ReleaseChannel } from "./runtime.js";
2
+ export type ClientConfigOptions = {
3
+ configPath: string;
4
+ dataDir: string;
5
+ workspacePath: string;
6
+ workspaceId: string;
7
+ workspaceName?: string;
8
+ tunnelId: string;
9
+ token: string;
10
+ serverUrl?: string;
11
+ preserveWorkspaces?: boolean;
12
+ };
13
+ type YamlObject = Record<string, unknown>;
14
+ export declare function ensureClientDirectories(dataDir: string, configPath: string): Promise<void>;
15
+ export declare function readConfigYaml(configPath: string): Promise<YamlObject>;
16
+ export declare function mergeClientConfig(existing: YamlObject, options: ClientConfigOptions): YamlObject;
17
+ export declare function readUpgradeChannel(configPath: string): Promise<ReleaseChannel>;
18
+ export declare function writeUpgradeChannel(configPath: string, channel: ReleaseChannel): Promise<void>;
19
+ export declare function writeConfigYaml(configPath: string, config: YamlObject): Promise<void>;
20
+ export declare function updateClientConfig(options: ClientConfigOptions): Promise<YamlObject>;
21
+ export declare function assertPrivateConfig(configPath: string): Promise<void>;
22
+ export {};
@@ -0,0 +1,100 @@
1
+ import { constants } from "node:fs";
2
+ import { access, chmod, mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
3
+ import { dirname, join } from "node:path";
4
+ import { parse, stringify } from "yaml";
5
+ import { normalizeReleaseChannel } from "./runtime.js";
6
+ function isRecord(value) {
7
+ return typeof value === "object" && value !== null && !Array.isArray(value);
8
+ }
9
+ function objectAt(config, key) {
10
+ const existing = config[key];
11
+ if (isRecord(existing))
12
+ return existing;
13
+ const next = {};
14
+ config[key] = next;
15
+ return next;
16
+ }
17
+ export async function ensureClientDirectories(dataDir, configPath) {
18
+ await mkdir(dirname(configPath), { recursive: true, mode: 0o700 });
19
+ await chmod(dirname(configPath), 0o700);
20
+ await mkdir(dataDir, { recursive: true, mode: 0o700 });
21
+ await mkdir(join(dataDir, "logs"), { recursive: true, mode: 0o700 });
22
+ await mkdir(join(dataDir, "uploads"), { recursive: true, mode: 0o700 });
23
+ await mkdir(join(dataDir, "runtime"), { recursive: true, mode: 0o700 });
24
+ }
25
+ async function fileExists(path) {
26
+ try {
27
+ await access(path, constants.F_OK);
28
+ return true;
29
+ }
30
+ catch {
31
+ return false;
32
+ }
33
+ }
34
+ export async function readConfigYaml(configPath) {
35
+ if (!(await fileExists(configPath)))
36
+ return {};
37
+ const parsed = parse(await readFile(configPath, "utf8"));
38
+ if (parsed === null || parsed === undefined)
39
+ return {};
40
+ if (!isRecord(parsed)) {
41
+ throw new Error(`Config file must contain a YAML object: ${configPath}`);
42
+ }
43
+ return parsed;
44
+ }
45
+ export function mergeClientConfig(existing, options) {
46
+ const config = { ...existing };
47
+ config.log_level ??= "info";
48
+ if (!options.preserveWorkspaces) {
49
+ config.workspaces = [
50
+ {
51
+ id: options.workspaceId,
52
+ name: options.workspaceName ?? `${options.tunnelId} Default`,
53
+ path: options.workspacePath,
54
+ },
55
+ ];
56
+ }
57
+ config.data_dir = options.dataDir;
58
+ const fileUploads = objectAt(config, "file_uploads");
59
+ fileUploads.temp_dir = join(options.dataDir, "uploads");
60
+ const tunnel = objectAt(config, "tunnel");
61
+ tunnel.enabled = true;
62
+ if (options.serverUrl)
63
+ tunnel.server_url = options.serverUrl;
64
+ tunnel.tunnel_id = options.tunnelId;
65
+ tunnel.token = options.token;
66
+ return config;
67
+ }
68
+ export async function readUpgradeChannel(configPath) {
69
+ const config = await readConfigYaml(configPath);
70
+ const autoUpgrade = config.auto_upgrade;
71
+ const value = isRecord(autoUpgrade) ? autoUpgrade.channel : undefined;
72
+ return normalizeReleaseChannel(value) ?? "latest";
73
+ }
74
+ export async function writeUpgradeChannel(configPath, channel) {
75
+ const config = await readConfigYaml(configPath);
76
+ const autoUpgrade = objectAt(config, "auto_upgrade");
77
+ autoUpgrade.channel = channel;
78
+ await writeConfigYaml(configPath, config);
79
+ }
80
+ export async function writeConfigYaml(configPath, config) {
81
+ await mkdir(dirname(configPath), { recursive: true, mode: 0o700 });
82
+ const tempPath = `${configPath}.${process.pid}.${Date.now()}.tmp`;
83
+ await writeFile(tempPath, stringify(config), { mode: 0o600 });
84
+ await chmod(tempPath, 0o600);
85
+ await rename(tempPath, configPath);
86
+ await chmod(configPath, 0o600);
87
+ }
88
+ export async function updateClientConfig(options) {
89
+ await ensureClientDirectories(options.dataDir, options.configPath);
90
+ const existing = await readConfigYaml(options.configPath);
91
+ const next = mergeClientConfig(existing, options);
92
+ await writeConfigYaml(options.configPath, next);
93
+ return next;
94
+ }
95
+ export async function assertPrivateConfig(configPath) {
96
+ const mode = (await stat(configPath)).mode & 0o777;
97
+ if ((mode & 0o077) !== 0) {
98
+ throw new Error(`Config file permissions are too broad: ${configPath}`);
99
+ }
100
+ }
@@ -0,0 +1,17 @@
1
+ import type { ClientActivityStatus, ManagedAgentName } from "./supervisor.js";
2
+ import type { MaintenanceScheduler, MaintenanceTask, ScheduleMaintenanceOptions } from "./maintenance.js";
3
+ export type ClientControlHandlers = {
4
+ restartAgent(agent: ManagedAgentName): Promise<void>;
5
+ getActivity(): Promise<ClientActivityStatus>;
6
+ maintenance: MaintenanceScheduler;
7
+ };
8
+ export type ClientControlServer = {
9
+ close(): Promise<void>;
10
+ };
11
+ export declare function readControlSocketPath(configPath: string): Promise<string>;
12
+ export declare function startControlServer(socketPath: string, handlers: ClientControlHandlers): Promise<ClientControlServer>;
13
+ export declare function requestAgentRestart(configPath: string, agent: ManagedAgentName): Promise<void>;
14
+ export declare function requestClientActivity(configPath: string): Promise<ClientActivityStatus>;
15
+ export declare function requestScheduleMaintenance(configPath: string, options: ScheduleMaintenanceOptions): Promise<MaintenanceTask>;
16
+ export declare function requestMaintenanceStatus(configPath: string): Promise<MaintenanceTask | undefined>;
17
+ export declare function requestMaintenanceCancel(configPath: string): Promise<MaintenanceTask>;
@@ -0,0 +1,152 @@
1
+ import { chmod, mkdir, rm } from "node:fs/promises";
2
+ import { createConnection, createServer } from "node:net";
3
+ import { dirname, join } from "node:path";
4
+ import { readConfigYaml } from "./config-file.js";
5
+ import { defaultDataDir } from "./paths.js";
6
+ export async function readControlSocketPath(configPath) {
7
+ const config = await readConfigYaml(configPath);
8
+ const dataDir = typeof config.data_dir === "string" && config.data_dir.trim() ? config.data_dir : defaultDataDir;
9
+ return join(dataDir, "runtime", "client.sock");
10
+ }
11
+ export async function startControlServer(socketPath, handlers) {
12
+ await mkdir(dirname(socketPath), { recursive: true, mode: 0o700 });
13
+ await rm(socketPath, { force: true });
14
+ const server = createServer({ allowHalfOpen: true }, (socket) => {
15
+ let input = "";
16
+ socket.setEncoding("utf8");
17
+ socket.on("error", () => {
18
+ // A caller may disconnect while an agent is still restarting.
19
+ });
20
+ socket.on("data", (chunk) => {
21
+ input += chunk;
22
+ });
23
+ socket.on("end", () => {
24
+ void handleRequest(input, handlers).then((response) => socket.end(`${JSON.stringify(response)}\n`));
25
+ });
26
+ });
27
+ await listen(server, socketPath);
28
+ await chmod(socketPath, 0o600);
29
+ return {
30
+ async close() {
31
+ await closeServer(server);
32
+ await rm(socketPath, { force: true });
33
+ },
34
+ };
35
+ }
36
+ export async function requestAgentRestart(configPath, agent) {
37
+ const socketPath = await readControlSocketPath(configPath);
38
+ const response = await sendRequest(socketPath, { action: "restart", agent }).catch((err) => {
39
+ const detail = err instanceof Error ? err.message : String(err);
40
+ throw new Error(`Unable to contact the coding-agent client service: ${detail}`);
41
+ });
42
+ if (!response.ok)
43
+ throw new Error(response.error ?? `Failed to restart ${agent} agent`);
44
+ }
45
+ export async function requestClientActivity(configPath) {
46
+ return requestData(configPath, { action: "activity" });
47
+ }
48
+ export async function requestScheduleMaintenance(configPath, options) {
49
+ return requestData(configPath, { action: "schedule-maintenance", ...options });
50
+ }
51
+ export async function requestMaintenanceStatus(configPath) {
52
+ return requestData(configPath, { action: "maintenance-status" });
53
+ }
54
+ export async function requestMaintenanceCancel(configPath) {
55
+ return requestData(configPath, { action: "maintenance-cancel" });
56
+ }
57
+ async function handleRequest(input, handlers) {
58
+ try {
59
+ const request = JSON.parse(input);
60
+ switch (request.action) {
61
+ case "restart":
62
+ if (request.agent !== "codex" && request.agent !== "claude") {
63
+ return { ok: false, error: "Invalid control request" };
64
+ }
65
+ await handlers.restartAgent(request.agent);
66
+ return { ok: true };
67
+ case "activity":
68
+ return { ok: true, data: await handlers.getActivity() };
69
+ case "schedule-maintenance":
70
+ if (!isMaintenanceOperation(request.operation) ||
71
+ !Number.isInteger(request.delaySeconds) ||
72
+ typeof request.allowWaiting !== "boolean") {
73
+ return { ok: false, error: "Invalid maintenance request" };
74
+ }
75
+ return {
76
+ ok: true,
77
+ data: await handlers.maintenance.schedule({
78
+ operation: request.operation,
79
+ delaySeconds: request.delaySeconds,
80
+ allowWaiting: request.allowWaiting,
81
+ }),
82
+ };
83
+ case "maintenance-status":
84
+ return { ok: true, data: handlers.maintenance.getTask() };
85
+ case "maintenance-cancel":
86
+ return { ok: true, data: await handlers.maintenance.cancel() };
87
+ default:
88
+ return { ok: false, error: "Invalid control request" };
89
+ }
90
+ }
91
+ catch (err) {
92
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
93
+ }
94
+ }
95
+ function isMaintenanceOperation(operation) {
96
+ if (!operation || typeof operation !== "object" || !("type" in operation))
97
+ return false;
98
+ if (operation.type === "upgrade") {
99
+ return "version" in operation && typeof operation.version === "string" && operation.version.length > 0;
100
+ }
101
+ if (operation.type === "restart") {
102
+ return !("agent" in operation &&
103
+ operation.agent !== undefined &&
104
+ operation.agent !== "codex" &&
105
+ operation.agent !== "claude");
106
+ }
107
+ return operation.type === "stop" || operation.type === "uninstall";
108
+ }
109
+ async function requestData(configPath, request) {
110
+ const socketPath = await readControlSocketPath(configPath);
111
+ const response = await sendRequest(socketPath, request).catch((err) => {
112
+ const detail = err instanceof Error ? err.message : String(err);
113
+ throw new Error(`Unable to contact the coding-agent client service: ${detail}`);
114
+ });
115
+ if (!response.ok)
116
+ throw new Error(response.error ?? "Client control request failed");
117
+ return response.data;
118
+ }
119
+ function listen(server, socketPath) {
120
+ return new Promise((resolve, reject) => {
121
+ server.once("error", reject);
122
+ server.listen(socketPath, () => {
123
+ server.off("error", reject);
124
+ resolve();
125
+ });
126
+ });
127
+ }
128
+ function closeServer(server) {
129
+ return new Promise((resolve, reject) => {
130
+ server.close((err) => (err ? reject(err) : resolve()));
131
+ });
132
+ }
133
+ function sendRequest(socketPath, request) {
134
+ return new Promise((resolve, reject) => {
135
+ const socket = createConnection(socketPath);
136
+ let output = "";
137
+ socket.setEncoding("utf8");
138
+ socket.once("error", reject);
139
+ socket.on("data", (chunk) => {
140
+ output += chunk;
141
+ });
142
+ socket.once("connect", () => socket.end(JSON.stringify(request)));
143
+ socket.once("end", () => {
144
+ try {
145
+ resolve(JSON.parse(output));
146
+ }
147
+ catch {
148
+ reject(new Error("Invalid response from the coding-agent client service"));
149
+ }
150
+ });
151
+ });
152
+ }
package/dist/exec.d.ts ADDED
@@ -0,0 +1,10 @@
1
+ export type CommandResult = {
2
+ status: number | null;
3
+ stdout: string;
4
+ stderr: string;
5
+ };
6
+ export declare function runCommand(command: string, args: string[], options?: {
7
+ allowFailure?: boolean;
8
+ interactive?: boolean;
9
+ env?: NodeJS.ProcessEnv;
10
+ }): Promise<CommandResult>;
package/dist/exec.js ADDED
@@ -0,0 +1,37 @@
1
+ import { spawn } from "node:child_process";
2
+ export function runCommand(command, args, options = {}) {
3
+ return new Promise((resolve, reject) => {
4
+ console.log(`$ ${command} ${args.join(" ")}`);
5
+ const child = spawn(command, args, {
6
+ stdio: options.interactive ? "inherit" : ["ignore", "pipe", "pipe"],
7
+ env: {
8
+ ...process.env,
9
+ ...options.env,
10
+ NODE_OPTIONS: "--use-system-ca",
11
+ },
12
+ });
13
+ const stdoutChunks = [];
14
+ const stderrChunks = [];
15
+ child.stdout?.on("data", (chunk) => {
16
+ // console.log(chunk.toString("utf8"))
17
+ stdoutChunks.push(chunk);
18
+ });
19
+ child.stderr?.on("data", (chunk) => {
20
+ // console.error(chunk.toString("utf8"))
21
+ stderrChunks.push(chunk);
22
+ });
23
+ child.on("error", reject);
24
+ child.on("close", (status) => {
25
+ const result = {
26
+ status,
27
+ stdout: Buffer.concat(stdoutChunks).toString("utf8"),
28
+ stderr: Buffer.concat(stderrChunks).toString("utf8"),
29
+ };
30
+ if (!options.allowFailure && status !== 0) {
31
+ reject(new Error(`${command} ${args.join(" ")} failed with status ${status}\n${result.stderr || result.stdout}`));
32
+ return;
33
+ }
34
+ resolve(result);
35
+ });
36
+ });
37
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};