@hasna/computer 0.1.8 → 0.1.9
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.
- package/LICENSE +4 -2
- package/README.md +73 -7
- package/dist/apps/ghostty/applescript.d.ts +36 -0
- package/dist/apps/ghostty/applescript.d.ts.map +1 -0
- package/dist/apps/ghostty/applescript.test.d.ts +2 -0
- package/dist/apps/ghostty/applescript.test.d.ts.map +1 -0
- package/dist/apps/ghostty/driver.d.ts +10 -0
- package/dist/apps/ghostty/driver.d.ts.map +1 -0
- package/dist/apps/registry.d.ts +5 -0
- package/dist/apps/registry.d.ts.map +1 -0
- package/dist/apps/registry.test.d.ts +2 -0
- package/dist/apps/registry.test.d.ts.map +1 -0
- package/dist/apps/types.d.ts +47 -0
- package/dist/apps/types.d.ts.map +1 -0
- package/dist/cli/index.js +1055 -91
- package/dist/cli/storage.d.ts +3 -0
- package/dist/cli/storage.d.ts.map +1 -0
- package/dist/cli/storage.test.d.ts +2 -0
- package/dist/cli/storage.test.d.ts.map +1 -0
- package/dist/db/storage-sync.d.ts +57 -0
- package/dist/db/storage-sync.d.ts.map +1 -0
- package/dist/db/storage-sync.test.d.ts +2 -0
- package/dist/db/storage-sync.test.d.ts.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +84 -26
- package/dist/mcp/http.d.ts +16 -0
- package/dist/mcp/http.d.ts.map +1 -0
- package/dist/mcp/http.test.d.ts +2 -0
- package/dist/mcp/http.test.d.ts.map +1 -0
- package/dist/mcp/index.d.ts +1 -1
- package/dist/mcp/index.d.ts.map +1 -1
- package/dist/mcp/index.js +609 -262
- package/dist/mcp/server.d.ts +4 -0
- package/dist/mcp/server.d.ts.map +1 -0
- package/dist/server/index.js +34565 -8747
- package/dist/storage.d.ts +5 -0
- package/dist/storage.d.ts.map +1 -0
- package/dist/storage.js +5519 -0
- package/package.json +7 -2
- package/dist/cli/cloud.d.ts +0 -3
- package/dist/cli/cloud.d.ts.map +0 -1
- package/dist/db/cloud-sync.d.ts +0 -33
- package/dist/db/cloud-sync.d.ts.map +0 -1
package/dist/cli/index.js
CHANGED
|
@@ -993,7 +993,7 @@ Expecting one of '${allowedValues.join("', '")}'`);
|
|
|
993
993
|
this._exitCallback = (err) => {
|
|
994
994
|
if (err.code !== "commander.executeSubCommandAsync") {
|
|
995
995
|
throw err;
|
|
996
|
-
}
|
|
996
|
+
}
|
|
997
997
|
};
|
|
998
998
|
}
|
|
999
999
|
return this;
|
|
@@ -2091,11 +2091,11 @@ __export(exports_config, {
|
|
|
2091
2091
|
getConfigPath: () => getConfigPath,
|
|
2092
2092
|
DEFAULT_CONFIG: () => DEFAULT_CONFIG
|
|
2093
2093
|
});
|
|
2094
|
-
import { join as
|
|
2095
|
-
import { existsSync as
|
|
2094
|
+
import { join as join5 } from "path";
|
|
2095
|
+
import { existsSync as existsSync3, readFileSync, writeFileSync, mkdirSync } from "fs";
|
|
2096
2096
|
function loadConfig() {
|
|
2097
2097
|
try {
|
|
2098
|
-
if (
|
|
2098
|
+
if (existsSync3(CONFIG_PATH)) {
|
|
2099
2099
|
const raw = readFileSync(CONFIG_PATH, "utf-8");
|
|
2100
2100
|
const user = JSON.parse(raw);
|
|
2101
2101
|
return mergeConfig(DEFAULT_CONFIG, user);
|
|
@@ -2162,8 +2162,8 @@ function getConfigPath() {
|
|
|
2162
2162
|
}
|
|
2163
2163
|
var CONFIG_DIR, CONFIG_PATH, DEFAULT_CONFIG;
|
|
2164
2164
|
var init_config = __esm(() => {
|
|
2165
|
-
CONFIG_DIR =
|
|
2166
|
-
CONFIG_PATH =
|
|
2165
|
+
CONFIG_DIR = join5(process.env.HOME ?? "~", ".hasna", "computer");
|
|
2166
|
+
CONFIG_PATH = join5(CONFIG_DIR, "config.json");
|
|
2167
2167
|
DEFAULT_CONFIG = {
|
|
2168
2168
|
provider: "anthropic",
|
|
2169
2169
|
maxSteps: 50,
|
|
@@ -7133,20 +7133,698 @@ var {
|
|
|
7133
7133
|
Help
|
|
7134
7134
|
} = import__.default;
|
|
7135
7135
|
|
|
7136
|
+
// node_modules/@hasna/events/dist/commander.js
|
|
7137
|
+
import { chmod, mkdir, readFile, rename, writeFile } from "fs/promises";
|
|
7138
|
+
import { existsSync } from "fs";
|
|
7139
|
+
import { homedir } from "os";
|
|
7140
|
+
import { join } from "path";
|
|
7141
|
+
import { createHmac, timingSafeEqual } from "crypto";
|
|
7142
|
+
import { randomUUID } from "crypto";
|
|
7143
|
+
import { spawn } from "child_process";
|
|
7144
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
7145
|
+
function getPathValue(input, path) {
|
|
7146
|
+
return path.split(".").reduce((value, part) => {
|
|
7147
|
+
if (value && typeof value === "object" && part in value) {
|
|
7148
|
+
return value[part];
|
|
7149
|
+
}
|
|
7150
|
+
return;
|
|
7151
|
+
}, input);
|
|
7152
|
+
}
|
|
7153
|
+
function wildcardToRegExp(pattern) {
|
|
7154
|
+
const escaped = pattern.replace(/[|\\{}()[\]^$+?.]/g, "\\$&").replace(/\*/g, ".*");
|
|
7155
|
+
return new RegExp(`^${escaped}$`);
|
|
7156
|
+
}
|
|
7157
|
+
function matchString(value, matcher) {
|
|
7158
|
+
if (matcher === undefined)
|
|
7159
|
+
return true;
|
|
7160
|
+
if (value === undefined)
|
|
7161
|
+
return false;
|
|
7162
|
+
const matchers = Array.isArray(matcher) ? matcher : [matcher];
|
|
7163
|
+
return matchers.some((item) => wildcardToRegExp(item).test(value));
|
|
7164
|
+
}
|
|
7165
|
+
function matchRecord(input, matcher) {
|
|
7166
|
+
if (!matcher)
|
|
7167
|
+
return true;
|
|
7168
|
+
return Object.entries(matcher).every(([path, expected]) => {
|
|
7169
|
+
const actual = getPathValue(input, path);
|
|
7170
|
+
if (typeof expected === "string" || Array.isArray(expected)) {
|
|
7171
|
+
return matchString(actual === undefined ? undefined : String(actual), expected);
|
|
7172
|
+
}
|
|
7173
|
+
return actual === expected;
|
|
7174
|
+
});
|
|
7175
|
+
}
|
|
7176
|
+
function eventMatchesFilter(event, filter) {
|
|
7177
|
+
return matchString(event.source, filter.source) && matchString(event.type, filter.type) && matchString(event.subject, filter.subject) && matchString(event.severity, filter.severity) && matchRecord(event.data, filter.data) && matchRecord(event.metadata, filter.metadata);
|
|
7178
|
+
}
|
|
7179
|
+
function channelMatchesEvent(channel, event) {
|
|
7180
|
+
if (!channel.enabled)
|
|
7181
|
+
return false;
|
|
7182
|
+
if (!channel.filters || channel.filters.length === 0)
|
|
7183
|
+
return true;
|
|
7184
|
+
return channel.filters.some((filter) => eventMatchesFilter(event, filter));
|
|
7185
|
+
}
|
|
7186
|
+
var HASNA_EVENTS_DIR_ENV = "HASNA_EVENTS_DIR";
|
|
7187
|
+
var HASNA_EVENTS_HOME_ENV = "HASNA_EVENTS_HOME";
|
|
7188
|
+
function getEventsDataDir(override) {
|
|
7189
|
+
return override || process.env[HASNA_EVENTS_DIR_ENV] || process.env[HASNA_EVENTS_HOME_ENV] || join(homedir(), ".hasna", "events");
|
|
7190
|
+
}
|
|
7191
|
+
|
|
7192
|
+
class JsonEventsStore {
|
|
7193
|
+
dataDir;
|
|
7194
|
+
channelsPath;
|
|
7195
|
+
eventsPath;
|
|
7196
|
+
deliveriesPath;
|
|
7197
|
+
constructor(dataDir = getEventsDataDir()) {
|
|
7198
|
+
this.dataDir = dataDir;
|
|
7199
|
+
this.channelsPath = join(dataDir, "channels.json");
|
|
7200
|
+
this.eventsPath = join(dataDir, "events.json");
|
|
7201
|
+
this.deliveriesPath = join(dataDir, "deliveries.json");
|
|
7202
|
+
}
|
|
7203
|
+
async init() {
|
|
7204
|
+
await mkdir(this.dataDir, { recursive: true, mode: 448 });
|
|
7205
|
+
await chmod(this.dataDir, 448).catch(() => {
|
|
7206
|
+
return;
|
|
7207
|
+
});
|
|
7208
|
+
await this.ensureArrayFile(this.channelsPath);
|
|
7209
|
+
await this.ensureArrayFile(this.eventsPath);
|
|
7210
|
+
await this.ensureArrayFile(this.deliveriesPath);
|
|
7211
|
+
}
|
|
7212
|
+
async addChannel(channel) {
|
|
7213
|
+
await this.init();
|
|
7214
|
+
const channels = await this.readJson(this.channelsPath, []);
|
|
7215
|
+
const index = channels.findIndex((item) => item.id === channel.id);
|
|
7216
|
+
if (index >= 0) {
|
|
7217
|
+
channels[index] = { ...channel, createdAt: channels[index].createdAt, updatedAt: new Date().toISOString() };
|
|
7218
|
+
} else {
|
|
7219
|
+
channels.push(channel);
|
|
7220
|
+
}
|
|
7221
|
+
await this.writeJson(this.channelsPath, channels);
|
|
7222
|
+
return index >= 0 ? channels[index] : channel;
|
|
7223
|
+
}
|
|
7224
|
+
async listChannels() {
|
|
7225
|
+
await this.init();
|
|
7226
|
+
return this.readJson(this.channelsPath, []);
|
|
7227
|
+
}
|
|
7228
|
+
async getChannel(id) {
|
|
7229
|
+
const channels = await this.listChannels();
|
|
7230
|
+
return channels.find((channel) => channel.id === id);
|
|
7231
|
+
}
|
|
7232
|
+
async removeChannel(id) {
|
|
7233
|
+
await this.init();
|
|
7234
|
+
const channels = await this.readJson(this.channelsPath, []);
|
|
7235
|
+
const next = channels.filter((channel) => channel.id !== id);
|
|
7236
|
+
await this.writeJson(this.channelsPath, next);
|
|
7237
|
+
return next.length !== channels.length;
|
|
7238
|
+
}
|
|
7239
|
+
async appendEvent(event) {
|
|
7240
|
+
await this.init();
|
|
7241
|
+
const events = await this.readJson(this.eventsPath, []);
|
|
7242
|
+
events.push(event);
|
|
7243
|
+
await this.writeJson(this.eventsPath, events);
|
|
7244
|
+
return event;
|
|
7245
|
+
}
|
|
7246
|
+
async listEvents() {
|
|
7247
|
+
await this.init();
|
|
7248
|
+
return this.readJson(this.eventsPath, []);
|
|
7249
|
+
}
|
|
7250
|
+
async findEventByIdentity(identity) {
|
|
7251
|
+
const events = await this.listEvents();
|
|
7252
|
+
return events.find((event) => identity.id !== undefined && event.id === identity.id || identity.dedupeKey !== undefined && event.dedupeKey === identity.dedupeKey);
|
|
7253
|
+
}
|
|
7254
|
+
async appendDelivery(result) {
|
|
7255
|
+
await this.init();
|
|
7256
|
+
const deliveries = await this.readJson(this.deliveriesPath, []);
|
|
7257
|
+
deliveries.push(result);
|
|
7258
|
+
await this.writeJson(this.deliveriesPath, deliveries);
|
|
7259
|
+
return result;
|
|
7260
|
+
}
|
|
7261
|
+
async listDeliveries() {
|
|
7262
|
+
await this.init();
|
|
7263
|
+
return this.readJson(this.deliveriesPath, []);
|
|
7264
|
+
}
|
|
7265
|
+
async exportData() {
|
|
7266
|
+
return {
|
|
7267
|
+
channels: await this.listChannels(),
|
|
7268
|
+
events: await this.listEvents(),
|
|
7269
|
+
deliveries: await this.listDeliveries()
|
|
7270
|
+
};
|
|
7271
|
+
}
|
|
7272
|
+
async ensureArrayFile(path) {
|
|
7273
|
+
if (!existsSync(path)) {
|
|
7274
|
+
await writeFile(path, `[]
|
|
7275
|
+
`, { encoding: "utf-8", mode: 384 });
|
|
7276
|
+
}
|
|
7277
|
+
await chmod(path, 384).catch(() => {
|
|
7278
|
+
return;
|
|
7279
|
+
});
|
|
7280
|
+
}
|
|
7281
|
+
async readJson(path, fallback) {
|
|
7282
|
+
try {
|
|
7283
|
+
const raw = await readFile(path, "utf-8");
|
|
7284
|
+
if (!raw.trim())
|
|
7285
|
+
return fallback;
|
|
7286
|
+
return JSON.parse(raw);
|
|
7287
|
+
} catch (error) {
|
|
7288
|
+
if (error.code === "ENOENT")
|
|
7289
|
+
return fallback;
|
|
7290
|
+
throw error;
|
|
7291
|
+
}
|
|
7292
|
+
}
|
|
7293
|
+
async writeJson(path, value) {
|
|
7294
|
+
const tempPath = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
7295
|
+
await writeFile(tempPath, `${JSON.stringify(value, null, 2)}
|
|
7296
|
+
`, { encoding: "utf-8", mode: 384 });
|
|
7297
|
+
await rename(tempPath, path);
|
|
7298
|
+
await chmod(path, 384).catch(() => {
|
|
7299
|
+
return;
|
|
7300
|
+
});
|
|
7301
|
+
}
|
|
7302
|
+
}
|
|
7303
|
+
var DEFAULT_SIGNATURE_TOLERANCE_MS = 5 * 60 * 1000;
|
|
7304
|
+
function buildSignatureBase(timestamp, body) {
|
|
7305
|
+
return `${timestamp}.${body}`;
|
|
7306
|
+
}
|
|
7307
|
+
function signPayload(secret, timestamp, body) {
|
|
7308
|
+
const digest = createHmac("sha256", secret).update(buildSignatureBase(timestamp, body)).digest("hex");
|
|
7309
|
+
return `sha256=${digest}`;
|
|
7310
|
+
}
|
|
7311
|
+
function now() {
|
|
7312
|
+
return new Date().toISOString();
|
|
7313
|
+
}
|
|
7314
|
+
function truncate(value, max = 4096) {
|
|
7315
|
+
return value.length > max ? `${value.slice(0, max)}...` : value;
|
|
7316
|
+
}
|
|
7317
|
+
function buildWebhookRequest(event, channel) {
|
|
7318
|
+
if (!channel.webhook)
|
|
7319
|
+
throw new Error(`Channel ${channel.id} has no webhook config`);
|
|
7320
|
+
const body = JSON.stringify(event);
|
|
7321
|
+
const timestamp = event.time;
|
|
7322
|
+
const headers = {
|
|
7323
|
+
"Content-Type": "application/json",
|
|
7324
|
+
"User-Agent": "@hasna/events",
|
|
7325
|
+
"X-Hasna-Event-Id": event.id,
|
|
7326
|
+
"X-Hasna-Event-Type": event.type,
|
|
7327
|
+
"X-Hasna-Timestamp": timestamp,
|
|
7328
|
+
...channel.webhook.headers
|
|
7329
|
+
};
|
|
7330
|
+
if (channel.webhook.secret) {
|
|
7331
|
+
headers["X-Hasna-Signature"] = signPayload(channel.webhook.secret, timestamp, body);
|
|
7332
|
+
}
|
|
7333
|
+
return { body, headers };
|
|
7334
|
+
}
|
|
7335
|
+
async function dispatchWebhook(event, channel, options = {}) {
|
|
7336
|
+
if (!channel.webhook)
|
|
7337
|
+
throw new Error(`Channel ${channel.id} has no webhook config`);
|
|
7338
|
+
const startedAt = now();
|
|
7339
|
+
const { body, headers } = buildWebhookRequest(event, channel);
|
|
7340
|
+
const controller = new AbortController;
|
|
7341
|
+
const timeout = setTimeout(() => controller.abort(), channel.webhook.timeoutMs ?? 15000);
|
|
7342
|
+
try {
|
|
7343
|
+
const response = await (options.fetchImpl ?? fetch)(channel.webhook.url, {
|
|
7344
|
+
method: "POST",
|
|
7345
|
+
headers,
|
|
7346
|
+
body,
|
|
7347
|
+
signal: controller.signal
|
|
7348
|
+
});
|
|
7349
|
+
const responseBody = truncate(await response.text());
|
|
7350
|
+
return {
|
|
7351
|
+
attempt: 1,
|
|
7352
|
+
status: response.ok ? "success" : "failed",
|
|
7353
|
+
startedAt,
|
|
7354
|
+
completedAt: now(),
|
|
7355
|
+
responseStatus: response.status,
|
|
7356
|
+
responseBody,
|
|
7357
|
+
error: response.ok ? undefined : `Webhook returned HTTP ${response.status}`
|
|
7358
|
+
};
|
|
7359
|
+
} catch (error) {
|
|
7360
|
+
return {
|
|
7361
|
+
attempt: 1,
|
|
7362
|
+
status: "failed",
|
|
7363
|
+
startedAt,
|
|
7364
|
+
completedAt: now(),
|
|
7365
|
+
error: error instanceof Error ? error.message : String(error)
|
|
7366
|
+
};
|
|
7367
|
+
} finally {
|
|
7368
|
+
clearTimeout(timeout);
|
|
7369
|
+
}
|
|
7370
|
+
}
|
|
7371
|
+
async function dispatchCommand(event, channel) {
|
|
7372
|
+
if (!channel.command)
|
|
7373
|
+
throw new Error(`Channel ${channel.id} has no command config`);
|
|
7374
|
+
const startedAt = now();
|
|
7375
|
+
const eventJson = JSON.stringify(event);
|
|
7376
|
+
const env = {
|
|
7377
|
+
...process.env,
|
|
7378
|
+
...channel.command.env,
|
|
7379
|
+
HASNA_CHANNEL_ID: channel.id,
|
|
7380
|
+
HASNA_EVENT_ID: event.id,
|
|
7381
|
+
HASNA_EVENT_TYPE: event.type,
|
|
7382
|
+
HASNA_EVENT_SOURCE: event.source,
|
|
7383
|
+
HASNA_EVENT_SUBJECT: event.subject ?? "",
|
|
7384
|
+
HASNA_EVENT_SEVERITY: event.severity,
|
|
7385
|
+
HASNA_EVENT_TIME: event.time,
|
|
7386
|
+
HASNA_EVENT_DEDUPE_KEY: event.dedupeKey ?? "",
|
|
7387
|
+
HASNA_EVENT_SCHEMA_VERSION: event.schemaVersion,
|
|
7388
|
+
HASNA_EVENT_JSON: eventJson
|
|
7389
|
+
};
|
|
7390
|
+
return new Promise((resolve) => {
|
|
7391
|
+
const child = spawn(channel.command.command, channel.command.args ?? [], {
|
|
7392
|
+
cwd: channel.command.cwd,
|
|
7393
|
+
env,
|
|
7394
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
7395
|
+
});
|
|
7396
|
+
let stdout = "";
|
|
7397
|
+
let stderr = "";
|
|
7398
|
+
const timeout = setTimeout(() => child.kill("SIGTERM"), channel.command.timeoutMs ?? 15000);
|
|
7399
|
+
child.stdin.end(eventJson);
|
|
7400
|
+
child.stdout.on("data", (chunk) => {
|
|
7401
|
+
stdout += chunk.toString();
|
|
7402
|
+
});
|
|
7403
|
+
child.stderr.on("data", (chunk) => {
|
|
7404
|
+
stderr += chunk.toString();
|
|
7405
|
+
});
|
|
7406
|
+
child.on("error", (error) => {
|
|
7407
|
+
clearTimeout(timeout);
|
|
7408
|
+
resolve({
|
|
7409
|
+
attempt: 1,
|
|
7410
|
+
status: "failed",
|
|
7411
|
+
startedAt,
|
|
7412
|
+
completedAt: now(),
|
|
7413
|
+
stdout: truncate(stdout),
|
|
7414
|
+
stderr: truncate(stderr),
|
|
7415
|
+
error: error.message
|
|
7416
|
+
});
|
|
7417
|
+
});
|
|
7418
|
+
child.on("close", (code, signal) => {
|
|
7419
|
+
clearTimeout(timeout);
|
|
7420
|
+
const success = code === 0;
|
|
7421
|
+
resolve({
|
|
7422
|
+
attempt: 1,
|
|
7423
|
+
status: success ? "success" : "failed",
|
|
7424
|
+
startedAt,
|
|
7425
|
+
completedAt: now(),
|
|
7426
|
+
stdout: truncate(stdout),
|
|
7427
|
+
stderr: truncate(stderr),
|
|
7428
|
+
error: success ? undefined : `Command exited with ${signal ? `signal ${signal}` : `code ${code}`}`
|
|
7429
|
+
});
|
|
7430
|
+
});
|
|
7431
|
+
});
|
|
7432
|
+
}
|
|
7433
|
+
async function dispatchChannel(event, channel, options = {}) {
|
|
7434
|
+
if (channel.transport === "webhook")
|
|
7435
|
+
return dispatchWebhook(event, channel, options);
|
|
7436
|
+
if (channel.transport === "command")
|
|
7437
|
+
return dispatchCommand(event, channel);
|
|
7438
|
+
return {
|
|
7439
|
+
attempt: 1,
|
|
7440
|
+
status: "skipped",
|
|
7441
|
+
startedAt: now(),
|
|
7442
|
+
completedAt: now(),
|
|
7443
|
+
error: `Unsupported transport: ${channel.transport}`
|
|
7444
|
+
};
|
|
7445
|
+
}
|
|
7446
|
+
function createDeliveryResult(event, channel, attempts) {
|
|
7447
|
+
const status = attempts.some((attempt) => attempt.status === "success") ? "success" : attempts.every((attempt) => attempt.status === "skipped") ? "skipped" : "failed";
|
|
7448
|
+
return {
|
|
7449
|
+
id: randomUUID(),
|
|
7450
|
+
eventId: event.id,
|
|
7451
|
+
channelId: channel.id,
|
|
7452
|
+
transport: channel.transport,
|
|
7453
|
+
status,
|
|
7454
|
+
attempts,
|
|
7455
|
+
createdAt: attempts[0]?.startedAt ?? now(),
|
|
7456
|
+
completedAt: attempts.at(-1)?.completedAt ?? now()
|
|
7457
|
+
};
|
|
7458
|
+
}
|
|
7459
|
+
function createEvent(input) {
|
|
7460
|
+
return {
|
|
7461
|
+
id: input.id ?? randomUUID2(),
|
|
7462
|
+
source: input.source,
|
|
7463
|
+
type: input.type,
|
|
7464
|
+
time: normalizeTime(input.time),
|
|
7465
|
+
subject: input.subject,
|
|
7466
|
+
severity: input.severity ?? "info",
|
|
7467
|
+
data: input.data ?? {},
|
|
7468
|
+
message: input.message,
|
|
7469
|
+
dedupeKey: input.dedupeKey,
|
|
7470
|
+
schemaVersion: input.schemaVersion ?? "1.0",
|
|
7471
|
+
metadata: input.metadata ?? {}
|
|
7472
|
+
};
|
|
7473
|
+
}
|
|
7474
|
+
|
|
7475
|
+
class EventsClient {
|
|
7476
|
+
store;
|
|
7477
|
+
redactors;
|
|
7478
|
+
transportOptions;
|
|
7479
|
+
constructor(options = {}) {
|
|
7480
|
+
this.store = options.store ?? new JsonEventsStore(options.dataDir);
|
|
7481
|
+
this.redactors = options.redactors ?? [];
|
|
7482
|
+
this.transportOptions = { fetchImpl: options.fetchImpl };
|
|
7483
|
+
}
|
|
7484
|
+
async addChannel(input) {
|
|
7485
|
+
const timestamp = new Date().toISOString();
|
|
7486
|
+
return this.store.addChannel({
|
|
7487
|
+
...input,
|
|
7488
|
+
createdAt: input.createdAt ?? timestamp,
|
|
7489
|
+
updatedAt: input.updatedAt ?? timestamp
|
|
7490
|
+
});
|
|
7491
|
+
}
|
|
7492
|
+
async listChannels() {
|
|
7493
|
+
return this.store.listChannels();
|
|
7494
|
+
}
|
|
7495
|
+
async removeChannel(id) {
|
|
7496
|
+
return this.store.removeChannel(id);
|
|
7497
|
+
}
|
|
7498
|
+
async emit(input, options = {}) {
|
|
7499
|
+
const event = options.redactSensitiveData === false ? createEvent(input) : redactSensitiveKeys(createEvent(input));
|
|
7500
|
+
if (options.dedupe !== false) {
|
|
7501
|
+
const existing = await this.store.findEventByIdentity({ id: input.id, dedupeKey: event.dedupeKey });
|
|
7502
|
+
if (existing) {
|
|
7503
|
+
return { event: existing, deliveries: [], deduped: true };
|
|
7504
|
+
}
|
|
7505
|
+
}
|
|
7506
|
+
await this.store.appendEvent(event);
|
|
7507
|
+
const deliveries = options.deliver === false ? [] : await this.deliver(event);
|
|
7508
|
+
return { event, deliveries, deduped: false };
|
|
7509
|
+
}
|
|
7510
|
+
async listEvents() {
|
|
7511
|
+
return this.store.listEvents();
|
|
7512
|
+
}
|
|
7513
|
+
async listDeliveries() {
|
|
7514
|
+
return this.store.listDeliveries();
|
|
7515
|
+
}
|
|
7516
|
+
async deliver(event) {
|
|
7517
|
+
const channels = await this.store.listChannels();
|
|
7518
|
+
const selected = channels.filter((channel) => channelMatchesEvent(channel, event));
|
|
7519
|
+
const deliveries = [];
|
|
7520
|
+
for (const channel of selected) {
|
|
7521
|
+
const eventForChannel = await this.applyRedaction(event, channel);
|
|
7522
|
+
const result = await this.deliverWithRetry(eventForChannel, channel);
|
|
7523
|
+
await this.store.appendDelivery(result);
|
|
7524
|
+
deliveries.push(result);
|
|
7525
|
+
}
|
|
7526
|
+
return deliveries;
|
|
7527
|
+
}
|
|
7528
|
+
async testChannel(id, input = {}) {
|
|
7529
|
+
const channel = await this.store.getChannel(id);
|
|
7530
|
+
if (!channel)
|
|
7531
|
+
throw new Error(`Channel not found: ${id}`);
|
|
7532
|
+
const event = createEvent({
|
|
7533
|
+
source: input.source ?? "hasna.events",
|
|
7534
|
+
type: input.type ?? "events.test",
|
|
7535
|
+
subject: input.subject ?? id,
|
|
7536
|
+
severity: input.severity ?? "info",
|
|
7537
|
+
data: input.data ?? { test: true },
|
|
7538
|
+
message: input.message ?? "Hasna events test delivery",
|
|
7539
|
+
dedupeKey: input.dedupeKey,
|
|
7540
|
+
schemaVersion: input.schemaVersion,
|
|
7541
|
+
metadata: input.metadata,
|
|
7542
|
+
time: input.time,
|
|
7543
|
+
id: input.id
|
|
7544
|
+
});
|
|
7545
|
+
const eventForChannel = await this.applyRedaction(event, channel);
|
|
7546
|
+
const result = await this.deliverWithRetry(eventForChannel, channel);
|
|
7547
|
+
await this.store.appendDelivery(result);
|
|
7548
|
+
return result;
|
|
7549
|
+
}
|
|
7550
|
+
async replay(options = {}) {
|
|
7551
|
+
const events = (await this.store.listEvents()).filter((event) => {
|
|
7552
|
+
if (options.eventId && event.id !== options.eventId)
|
|
7553
|
+
return false;
|
|
7554
|
+
if (options.source && event.source !== options.source)
|
|
7555
|
+
return false;
|
|
7556
|
+
if (options.type && event.type !== options.type)
|
|
7557
|
+
return false;
|
|
7558
|
+
return true;
|
|
7559
|
+
});
|
|
7560
|
+
if (options.dryRun)
|
|
7561
|
+
return { events, deliveries: [] };
|
|
7562
|
+
const deliveries = [];
|
|
7563
|
+
for (const event of events) {
|
|
7564
|
+
deliveries.push(...await this.deliver(event));
|
|
7565
|
+
}
|
|
7566
|
+
return { events, deliveries };
|
|
7567
|
+
}
|
|
7568
|
+
async applyRedaction(event, channel) {
|
|
7569
|
+
let next = redactPaths(event, channel.redact?.paths ?? [], channel.redact?.replacement ?? "[REDACTED]");
|
|
7570
|
+
for (const redactor of this.redactors) {
|
|
7571
|
+
next = await redactor(next, channel);
|
|
7572
|
+
}
|
|
7573
|
+
return next;
|
|
7574
|
+
}
|
|
7575
|
+
async deliverWithRetry(event, channel) {
|
|
7576
|
+
const policy = normalizeRetryPolicy(channel.retry);
|
|
7577
|
+
const attempts = [];
|
|
7578
|
+
for (let index = 0;index < policy.maxAttempts; index += 1) {
|
|
7579
|
+
const attempt = await dispatchChannel(event, channel, this.transportOptions);
|
|
7580
|
+
attempt.attempt = index + 1;
|
|
7581
|
+
if (attempt.status === "failed" && index + 1 < policy.maxAttempts) {
|
|
7582
|
+
attempt.nextBackoffMs = Math.round(policy.backoffMs * policy.multiplier ** index);
|
|
7583
|
+
}
|
|
7584
|
+
attempts.push(attempt);
|
|
7585
|
+
if (attempt.status !== "failed")
|
|
7586
|
+
break;
|
|
7587
|
+
if (attempt.nextBackoffMs)
|
|
7588
|
+
await Bun.sleep(attempt.nextBackoffMs);
|
|
7589
|
+
}
|
|
7590
|
+
return createDeliveryResult(event, channel, attempts);
|
|
7591
|
+
}
|
|
7592
|
+
}
|
|
7593
|
+
function redactPaths(event, paths, replacement = "[REDACTED]") {
|
|
7594
|
+
if (paths.length === 0)
|
|
7595
|
+
return event;
|
|
7596
|
+
const copy = structuredClone(event);
|
|
7597
|
+
for (const path of paths) {
|
|
7598
|
+
setPath(copy, path, replacement);
|
|
7599
|
+
}
|
|
7600
|
+
return copy;
|
|
7601
|
+
}
|
|
7602
|
+
function sanitizeChannelForOutput(channel) {
|
|
7603
|
+
const copy = structuredClone(channel);
|
|
7604
|
+
if (copy.webhook?.secret)
|
|
7605
|
+
copy.webhook.secret = "[REDACTED]";
|
|
7606
|
+
if (copy.command?.env) {
|
|
7607
|
+
copy.command.env = Object.fromEntries(Object.entries(copy.command.env).map(([key, value]) => [key, shouldRedactKey(key) ? "[REDACTED]" : value]));
|
|
7608
|
+
}
|
|
7609
|
+
return copy;
|
|
7610
|
+
}
|
|
7611
|
+
function sanitizeChannelsForOutput(channels) {
|
|
7612
|
+
return channels.map(sanitizeChannelForOutput);
|
|
7613
|
+
}
|
|
7614
|
+
function redactSensitiveKeys(event, replacement = "[REDACTED]") {
|
|
7615
|
+
return redactValue(event, replacement);
|
|
7616
|
+
}
|
|
7617
|
+
function shouldRedactKey(key) {
|
|
7618
|
+
return /secret|token|password|api[_-]?key|authorization/i.test(key);
|
|
7619
|
+
}
|
|
7620
|
+
function redactValue(value, replacement) {
|
|
7621
|
+
if (Array.isArray(value))
|
|
7622
|
+
return value.map((item) => redactValue(item, replacement));
|
|
7623
|
+
if (!value || typeof value !== "object")
|
|
7624
|
+
return value;
|
|
7625
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [
|
|
7626
|
+
key,
|
|
7627
|
+
shouldRedactKey(key) ? replacement : redactValue(item, replacement)
|
|
7628
|
+
]));
|
|
7629
|
+
}
|
|
7630
|
+
function setPath(input, path, replacement) {
|
|
7631
|
+
const parts = path.split(".");
|
|
7632
|
+
let cursor = input;
|
|
7633
|
+
for (const part of parts.slice(0, -1)) {
|
|
7634
|
+
const next = cursor[part];
|
|
7635
|
+
if (!next || typeof next !== "object")
|
|
7636
|
+
return;
|
|
7637
|
+
cursor = next;
|
|
7638
|
+
}
|
|
7639
|
+
const last = parts.at(-1);
|
|
7640
|
+
if (last && last in cursor)
|
|
7641
|
+
cursor[last] = replacement;
|
|
7642
|
+
}
|
|
7643
|
+
function normalizeTime(value) {
|
|
7644
|
+
if (!value)
|
|
7645
|
+
return new Date().toISOString();
|
|
7646
|
+
return value instanceof Date ? value.toISOString() : value;
|
|
7647
|
+
}
|
|
7648
|
+
function normalizeRetryPolicy(policy) {
|
|
7649
|
+
return {
|
|
7650
|
+
maxAttempts: Math.max(1, policy?.maxAttempts ?? 1),
|
|
7651
|
+
backoffMs: Math.max(0, policy?.backoffMs ?? 250),
|
|
7652
|
+
multiplier: Math.max(1, policy?.multiplier ?? 2)
|
|
7653
|
+
};
|
|
7654
|
+
}
|
|
7655
|
+
function parseJsonObject(value, fallback) {
|
|
7656
|
+
if (!value)
|
|
7657
|
+
return fallback;
|
|
7658
|
+
const parsed = JSON.parse(value);
|
|
7659
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
7660
|
+
throw new Error("Expected a JSON object");
|
|
7661
|
+
}
|
|
7662
|
+
return parsed;
|
|
7663
|
+
}
|
|
7664
|
+
function parseHeaders(values) {
|
|
7665
|
+
if (!values?.length)
|
|
7666
|
+
return;
|
|
7667
|
+
const headers = {};
|
|
7668
|
+
for (const value of values) {
|
|
7669
|
+
const separator = value.indexOf("=");
|
|
7670
|
+
if (separator === -1)
|
|
7671
|
+
throw new Error(`Invalid header, expected name=value: ${value}`);
|
|
7672
|
+
headers[value.slice(0, separator)] = value.slice(separator + 1);
|
|
7673
|
+
}
|
|
7674
|
+
return headers;
|
|
7675
|
+
}
|
|
7676
|
+
function parseFilter(options) {
|
|
7677
|
+
const filter2 = {};
|
|
7678
|
+
if (options.source)
|
|
7679
|
+
filter2.source = options.source;
|
|
7680
|
+
if (options.type)
|
|
7681
|
+
filter2.type = options.type;
|
|
7682
|
+
if (options.subject)
|
|
7683
|
+
filter2.subject = options.subject;
|
|
7684
|
+
if (options.severity)
|
|
7685
|
+
filter2.severity = options.severity;
|
|
7686
|
+
return Object.keys(filter2).length > 0 ? [filter2] : undefined;
|
|
7687
|
+
}
|
|
7688
|
+
function createClient(options) {
|
|
7689
|
+
if (options.createClient)
|
|
7690
|
+
return options.createClient();
|
|
7691
|
+
return new EventsClient({ store: new JsonEventsStore(options.dataDir) });
|
|
7692
|
+
}
|
|
7693
|
+
function print(value, json, text) {
|
|
7694
|
+
if (json)
|
|
7695
|
+
console.log(JSON.stringify(value, null, 2));
|
|
7696
|
+
else
|
|
7697
|
+
console.log(text);
|
|
7698
|
+
}
|
|
7699
|
+
function registerWebhookCommands(program2, options) {
|
|
7700
|
+
const webhooks = program2.command(options.webhooksCommandName ?? "webhooks").description("Manage Hasna event webhook subscriptions");
|
|
7701
|
+
webhooks.command("add").description("Add or replace a webhook or command subscription").argument("<target>", "Webhook URL or command binary").requiredOption("--id <id>", "Subscription/channel identifier").option("--transport <kind>", "Transport kind: webhook or command", "webhook").option("--name <name>", "Display name").option("--type <pattern>", "Event type filter, e.g. todos.task.*").option("--source <pattern>", "Event source filter").option("--subject <pattern>", "Event subject filter").option("--severity <pattern>", "Event severity filter").option("--secret <secret>", "Webhook HMAC secret").option("--header <name=value...>", "Webhook header", collectValues, []).option("--arg <arg...>", "Command argument", collectValues, []).option("--timeout-ms <ms>", "Transport timeout in milliseconds", parseNumber).option("--retry-attempts <n>", "Maximum delivery attempts", parseNumber).option("--retry-backoff-ms <ms>", "Initial retry backoff in milliseconds", parseNumber).option("--redact <path...>", "Event field path to redact before delivery", collectValues, []).option("--disabled", "Create channel disabled", false).option("-j, --json", "Print JSON output", false).action(async (target, actionOptions) => {
|
|
7702
|
+
const timestamp = new Date().toISOString();
|
|
7703
|
+
const channel = {
|
|
7704
|
+
id: actionOptions.id,
|
|
7705
|
+
name: actionOptions.name,
|
|
7706
|
+
enabled: !actionOptions.disabled,
|
|
7707
|
+
transport: actionOptions.transport,
|
|
7708
|
+
filters: parseFilter(actionOptions),
|
|
7709
|
+
retry: actionOptions.retryAttempts || actionOptions.retryBackoffMs ? { maxAttempts: actionOptions.retryAttempts, backoffMs: actionOptions.retryBackoffMs } : undefined,
|
|
7710
|
+
redact: actionOptions.redact?.length ? { paths: actionOptions.redact } : undefined,
|
|
7711
|
+
createdAt: timestamp,
|
|
7712
|
+
updatedAt: timestamp
|
|
7713
|
+
};
|
|
7714
|
+
if (actionOptions.transport === "webhook") {
|
|
7715
|
+
channel.webhook = { url: target, secret: actionOptions.secret, headers: parseHeaders(actionOptions.header), timeoutMs: actionOptions.timeoutMs };
|
|
7716
|
+
} else if (actionOptions.transport === "command") {
|
|
7717
|
+
channel.command = { command: target, args: actionOptions.arg ?? [], timeoutMs: actionOptions.timeoutMs };
|
|
7718
|
+
} else {
|
|
7719
|
+
throw new Error(`Transport ${actionOptions.transport} is reserved for future use and cannot be added yet`);
|
|
7720
|
+
}
|
|
7721
|
+
const saved = await createClient(options).addChannel(channel);
|
|
7722
|
+
print(sanitizeChannelForOutput(saved), Boolean(actionOptions.json), `Added ${saved.transport} channel ${saved.id}`);
|
|
7723
|
+
});
|
|
7724
|
+
webhooks.command("list").description("List configured subscriptions").option("-j, --json", "Print JSON output", false).action(async (actionOptions) => {
|
|
7725
|
+
const channels = await createClient(options).listChannels();
|
|
7726
|
+
if (actionOptions.json) {
|
|
7727
|
+
console.log(JSON.stringify(sanitizeChannelsForOutput(channels), null, 2));
|
|
7728
|
+
return;
|
|
7729
|
+
}
|
|
7730
|
+
if (!channels.length) {
|
|
7731
|
+
console.log("No channels configured.");
|
|
7732
|
+
return;
|
|
7733
|
+
}
|
|
7734
|
+
for (const channel of channels) {
|
|
7735
|
+
console.log(`${channel.id} ${channel.enabled ? "enabled" : "disabled"} ${channel.transport} ${channel.webhook?.url ?? channel.command?.command ?? channel.transport}`);
|
|
7736
|
+
}
|
|
7737
|
+
});
|
|
7738
|
+
webhooks.command("remove").description("Remove a subscription").argument("<id>", "Subscription/channel identifier").option("-j, --json", "Print JSON output", false).action(async (id, actionOptions) => {
|
|
7739
|
+
const removed = await createClient(options).removeChannel(id);
|
|
7740
|
+
print({ removed }, Boolean(actionOptions.json), removed ? `Removed ${id}` : `Channel not found: ${id}`);
|
|
7741
|
+
});
|
|
7742
|
+
webhooks.command("test").description("Send a test event to one subscription").argument("<id>", "Subscription/channel identifier").option("--type <type>", "Event type", "events.test").option("--subject <subject>", "Event subject").option("--message <message>", "Event message", "Hasna events test delivery").option("--data <json>", "Event data JSON object").option("-j, --json", "Print JSON output", false).action(async (id, actionOptions) => {
|
|
7743
|
+
const result = await createClient(options).testChannel(id, {
|
|
7744
|
+
source: options.source,
|
|
7745
|
+
type: actionOptions.type,
|
|
7746
|
+
subject: actionOptions.subject ?? id,
|
|
7747
|
+
message: actionOptions.message,
|
|
7748
|
+
data: parseJsonObject(actionOptions.data, { test: true })
|
|
7749
|
+
});
|
|
7750
|
+
print(result, Boolean(actionOptions.json), `${result.status}: ${result.channelId}`);
|
|
7751
|
+
});
|
|
7752
|
+
return webhooks;
|
|
7753
|
+
}
|
|
7754
|
+
function registerEventCommands(program2, options) {
|
|
7755
|
+
const events = program2.command(options.eventsCommandName ?? "events").description("Emit, list, and replay Hasna events");
|
|
7756
|
+
events.command("emit").description("Emit an event from this app").argument("<type>", "Event type").option("--source <source>", "Event source override").option("--subject <subject>", "Event subject").option("--severity <severity>", "Event severity", "info").option("--message <message>", "Event message").option("--dedupe-key <key>", "Dedupe key").option("--data <json>", "Event data JSON object").option("--metadata <json>", "Event metadata JSON object").option("--no-deliver", "Record without delivering").option("--no-dedupe", "Allow duplicate id/dedupeKey events").option("-j, --json", "Print JSON output", false).action(async (type, actionOptions) => {
|
|
7757
|
+
const result = await createClient(options).emit({
|
|
7758
|
+
source: actionOptions.source ?? options.source,
|
|
7759
|
+
type,
|
|
7760
|
+
subject: actionOptions.subject,
|
|
7761
|
+
severity: actionOptions.severity,
|
|
7762
|
+
message: actionOptions.message,
|
|
7763
|
+
dedupeKey: actionOptions.dedupeKey,
|
|
7764
|
+
data: parseJsonObject(actionOptions.data, {}),
|
|
7765
|
+
metadata: parseJsonObject(actionOptions.metadata, {})
|
|
7766
|
+
}, { deliver: actionOptions.deliver, dedupe: actionOptions.dedupe });
|
|
7767
|
+
print(result, Boolean(actionOptions.json), `${result.deduped ? "Deduped" : "Emitted"} ${result.event.id} to ${result.deliveries.length} channel(s)`);
|
|
7768
|
+
});
|
|
7769
|
+
events.command("list").description("List recorded events").option("--source <source>", "Filter by source").option("--type <type>", "Filter by type").option("--limit <n>", "Limit results", parseNumber).option("-j, --json", "Print JSON output", false).action(async (actionOptions) => {
|
|
7770
|
+
let rows = await createClient(options).listEvents();
|
|
7771
|
+
if (actionOptions.source)
|
|
7772
|
+
rows = rows.filter((event) => event.source === actionOptions.source);
|
|
7773
|
+
if (actionOptions.type)
|
|
7774
|
+
rows = rows.filter((event) => event.type === actionOptions.type);
|
|
7775
|
+
if (actionOptions.limit)
|
|
7776
|
+
rows = rows.slice(-actionOptions.limit);
|
|
7777
|
+
if (actionOptions.json) {
|
|
7778
|
+
console.log(JSON.stringify(rows, null, 2));
|
|
7779
|
+
return;
|
|
7780
|
+
}
|
|
7781
|
+
if (!rows.length) {
|
|
7782
|
+
console.log("No events recorded.");
|
|
7783
|
+
return;
|
|
7784
|
+
}
|
|
7785
|
+
for (const event of rows)
|
|
7786
|
+
console.log(`${event.time} ${event.id} ${event.source} ${event.type} ${event.severity}`);
|
|
7787
|
+
});
|
|
7788
|
+
events.command("replay").description("Replay recorded events").option("--id <id>", "Replay one event id").option("--source <source>", "Filter by source").option("--type <type>", "Filter by type").option("--dry-run", "Preview without delivery", false).option("-j, --json", "Print JSON output", false).action(async (actionOptions) => {
|
|
7789
|
+
const result = await createClient(options).replay({
|
|
7790
|
+
eventId: actionOptions.id,
|
|
7791
|
+
source: actionOptions.source,
|
|
7792
|
+
type: actionOptions.type,
|
|
7793
|
+
dryRun: actionOptions.dryRun
|
|
7794
|
+
});
|
|
7795
|
+
print(result, Boolean(actionOptions.json), `Replayed ${result.events.length} event(s), ${result.deliveries.length} delivery result(s)`);
|
|
7796
|
+
});
|
|
7797
|
+
return events;
|
|
7798
|
+
}
|
|
7799
|
+
function registerEventsCommands(program2, options) {
|
|
7800
|
+
registerWebhookCommands(program2, options);
|
|
7801
|
+
registerEventCommands(program2, options);
|
|
7802
|
+
}
|
|
7803
|
+
function parseNumber(value) {
|
|
7804
|
+
const parsed = Number(value);
|
|
7805
|
+
if (!Number.isFinite(parsed))
|
|
7806
|
+
throw new Error(`Expected a number, got ${value}`);
|
|
7807
|
+
return parsed;
|
|
7808
|
+
}
|
|
7809
|
+
function collectValues(value, previous) {
|
|
7810
|
+
previous.push(value);
|
|
7811
|
+
return previous;
|
|
7812
|
+
}
|
|
7813
|
+
|
|
7136
7814
|
// src/cli/index.ts
|
|
7137
7815
|
import chalk from "chalk";
|
|
7138
7816
|
|
|
7139
7817
|
// src/agent/loop.ts
|
|
7140
|
-
import { randomUUID } from "crypto";
|
|
7141
|
-
import { mkdir } from "fs/promises";
|
|
7818
|
+
import { randomUUID as randomUUID3 } from "crypto";
|
|
7819
|
+
import { mkdir as mkdir2 } from "fs/promises";
|
|
7142
7820
|
|
|
7143
7821
|
// src/drivers/mac/screenshot.ts
|
|
7144
7822
|
import { tmpdir } from "os";
|
|
7145
|
-
import { join } from "path";
|
|
7146
|
-
import { readFile, unlink } from "fs/promises";
|
|
7823
|
+
import { join as join2 } from "path";
|
|
7824
|
+
import { readFile as readFile2, unlink } from "fs/promises";
|
|
7147
7825
|
async function captureScreenshot(displayNumber) {
|
|
7148
7826
|
const timestamp = Date.now();
|
|
7149
|
-
const tmpPath =
|
|
7827
|
+
const tmpPath = join2(tmpdir(), `computer-screenshot-${timestamp}.png`);
|
|
7150
7828
|
const args = ["screencapture", "-x", "-C", "-t", "png"];
|
|
7151
7829
|
if (displayNumber) {
|
|
7152
7830
|
args.push(`-D${displayNumber}`);
|
|
@@ -7161,7 +7839,7 @@ async function captureScreenshot(displayNumber) {
|
|
|
7161
7839
|
const stderr = await new Response(proc.stderr).text();
|
|
7162
7840
|
throw new Error(`screencapture failed: ${stderr}`);
|
|
7163
7841
|
}
|
|
7164
|
-
const data = await
|
|
7842
|
+
const data = await readFile2(tmpPath);
|
|
7165
7843
|
const base64 = data.toString("base64");
|
|
7166
7844
|
await unlink(tmpPath).catch(() => {});
|
|
7167
7845
|
const size = await getScreenSize();
|
|
@@ -7185,15 +7863,15 @@ async function getScreenSize() {
|
|
|
7185
7863
|
return { width: 1920, height: 1080 };
|
|
7186
7864
|
}
|
|
7187
7865
|
async function saveScreenshotToFile(screenshot, dir, filename) {
|
|
7188
|
-
const path =
|
|
7866
|
+
const path = join2(dir, filename);
|
|
7189
7867
|
const buffer = Buffer.from(screenshot.base64, "base64");
|
|
7190
7868
|
await Bun.write(path, buffer);
|
|
7191
7869
|
return path;
|
|
7192
7870
|
}
|
|
7193
7871
|
|
|
7194
7872
|
// src/drivers/mac/input.ts
|
|
7195
|
-
import { join as
|
|
7196
|
-
import { existsSync } from "fs";
|
|
7873
|
+
import { join as join3, dirname } from "path";
|
|
7874
|
+
import { existsSync as existsSync2 } from "fs";
|
|
7197
7875
|
import { fileURLToPath } from "url";
|
|
7198
7876
|
async function executeAction(action) {
|
|
7199
7877
|
const start = Date.now();
|
|
@@ -7360,12 +8038,12 @@ function getScrollHelperPath() {
|
|
|
7360
8038
|
if (_scrollHelperPath)
|
|
7361
8039
|
return _scrollHelperPath;
|
|
7362
8040
|
const candidates = [
|
|
7363
|
-
|
|
7364
|
-
|
|
7365
|
-
|
|
8041
|
+
join3(dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "helpers", "scroll"),
|
|
8042
|
+
join3(dirname(fileURLToPath(import.meta.url)), "..", "helpers", "scroll"),
|
|
8043
|
+
join3(process.env.HOME ?? "~", ".hasna", "computer", "helpers", "scroll")
|
|
7366
8044
|
];
|
|
7367
8045
|
for (const candidate of candidates) {
|
|
7368
|
-
if (
|
|
8046
|
+
if (existsSync2(candidate)) {
|
|
7369
8047
|
_scrollHelperPath = candidate;
|
|
7370
8048
|
return candidate;
|
|
7371
8049
|
}
|
|
@@ -16376,8 +17054,8 @@ function createProvider(provider, opts) {
|
|
|
16376
17054
|
|
|
16377
17055
|
// src/lib/scale.ts
|
|
16378
17056
|
import { tmpdir as tmpdir2 } from "os";
|
|
16379
|
-
import { join as
|
|
16380
|
-
import { readFile as
|
|
17057
|
+
import { join as join4 } from "path";
|
|
17058
|
+
import { readFile as readFile3, unlink as unlink2, writeFile as writeFile2 } from "fs/promises";
|
|
16381
17059
|
var RECOMMENDED_WIDTHS = {
|
|
16382
17060
|
xga: 1024,
|
|
16383
17061
|
wxga: 1280,
|
|
@@ -16392,11 +17070,11 @@ async function scaleScreenshot(screenshot, maxWidth = DEFAULT_MAX_WIDTH) {
|
|
|
16392
17070
|
const newWidth = maxWidth;
|
|
16393
17071
|
const newHeight = Math.round(screenshot.size.height * ratio);
|
|
16394
17072
|
const timestamp = Date.now();
|
|
16395
|
-
const tmpInput =
|
|
16396
|
-
const tmpOutput =
|
|
17073
|
+
const tmpInput = join4(tmpdir2(), `computer-scale-in-${timestamp}.png`);
|
|
17074
|
+
const tmpOutput = join4(tmpdir2(), `computer-scale-out-${timestamp}.png`);
|
|
16397
17075
|
try {
|
|
16398
17076
|
const buffer = Buffer.from(screenshot.base64, "base64");
|
|
16399
|
-
await
|
|
17077
|
+
await writeFile2(tmpInput, buffer);
|
|
16400
17078
|
const proc = Bun.spawn([
|
|
16401
17079
|
"sips",
|
|
16402
17080
|
"--resampleWidth",
|
|
@@ -16411,7 +17089,7 @@ async function scaleScreenshot(screenshot, maxWidth = DEFAULT_MAX_WIDTH) {
|
|
|
16411
17089
|
console.warn(`sips resize failed (${stderr}), using original screenshot`);
|
|
16412
17090
|
return screenshot;
|
|
16413
17091
|
}
|
|
16414
|
-
const resized = await
|
|
17092
|
+
const resized = await readFile3(tmpOutput);
|
|
16415
17093
|
const base64 = resized.toString("base64");
|
|
16416
17094
|
return {
|
|
16417
17095
|
base64,
|
|
@@ -16520,8 +17198,8 @@ function checkAction(action, config) {
|
|
|
16520
17198
|
}
|
|
16521
17199
|
}
|
|
16522
17200
|
function checkRateLimit(maxPerMinute) {
|
|
16523
|
-
const
|
|
16524
|
-
const oneMinuteAgo =
|
|
17201
|
+
const now2 = Date.now();
|
|
17202
|
+
const oneMinuteAgo = now2 - 60000;
|
|
16525
17203
|
actionTimestamps = actionTimestamps.filter((t) => t > oneMinuteAgo);
|
|
16526
17204
|
if (actionTimestamps.length >= maxPerMinute) {
|
|
16527
17205
|
return {
|
|
@@ -16529,7 +17207,7 @@ function checkRateLimit(maxPerMinute) {
|
|
|
16529
17207
|
reason: `Rate limit exceeded: ${actionTimestamps.length}/${maxPerMinute} actions per minute`
|
|
16530
17208
|
};
|
|
16531
17209
|
}
|
|
16532
|
-
actionTimestamps.push(
|
|
17210
|
+
actionTimestamps.push(now2);
|
|
16533
17211
|
return { allowed: true };
|
|
16534
17212
|
}
|
|
16535
17213
|
function looksLikePassword(text) {
|
|
@@ -16575,26 +17253,26 @@ function screenshotsMatch(prev, curr, threshold = 0.98) {
|
|
|
16575
17253
|
|
|
16576
17254
|
// src/db/index.ts
|
|
16577
17255
|
import { Database } from "bun:sqlite";
|
|
16578
|
-
import { join as
|
|
17256
|
+
import { join as join6 } from "path";
|
|
16579
17257
|
import { mkdirSync as mkdirSync2 } from "fs";
|
|
16580
|
-
import { homedir } from "os";
|
|
17258
|
+
import { homedir as homedir2 } from "os";
|
|
16581
17259
|
var SERVICE_NAME = "computer";
|
|
16582
17260
|
function getDataDir(service = SERVICE_NAME) {
|
|
16583
17261
|
if (service === SERVICE_NAME && process.env["COMPUTER_DATA_DIR"]) {
|
|
16584
17262
|
mkdirSync2(process.env["COMPUTER_DATA_DIR"], { recursive: true });
|
|
16585
17263
|
return process.env["COMPUTER_DATA_DIR"];
|
|
16586
17264
|
}
|
|
16587
|
-
const home = process.env["HOME"] || process.env["USERPROFILE"] ||
|
|
16588
|
-
const dir =
|
|
17265
|
+
const home = process.env["HOME"] || process.env["USERPROFILE"] || homedir2();
|
|
17266
|
+
const dir = join6(home, ".hasna", service);
|
|
16589
17267
|
mkdirSync2(dir, { recursive: true });
|
|
16590
17268
|
return dir;
|
|
16591
17269
|
}
|
|
16592
17270
|
function getDbPath(service = SERVICE_NAME) {
|
|
16593
17271
|
if (service === SERVICE_NAME && process.env["COMPUTER_DB_PATH"]) {
|
|
16594
|
-
mkdirSync2(
|
|
17272
|
+
mkdirSync2(join6(process.env["COMPUTER_DB_PATH"], ".."), { recursive: true });
|
|
16595
17273
|
return process.env["COMPUTER_DB_PATH"];
|
|
16596
17274
|
}
|
|
16597
|
-
return
|
|
17275
|
+
return join6(getDataDir(service), `${service}.db`);
|
|
16598
17276
|
}
|
|
16599
17277
|
var db = null;
|
|
16600
17278
|
function getDb() {
|
|
@@ -16910,7 +17588,7 @@ async function runTask(options) {
|
|
|
16910
17588
|
const provider = createProvider(providerName, { model });
|
|
16911
17589
|
const config = loadConfig();
|
|
16912
17590
|
const safetyConfig = config.safety;
|
|
16913
|
-
const sessionId =
|
|
17591
|
+
const sessionId = randomUUID3();
|
|
16914
17592
|
const session = {
|
|
16915
17593
|
id: sessionId,
|
|
16916
17594
|
task,
|
|
@@ -16927,7 +17605,7 @@ async function runTask(options) {
|
|
|
16927
17605
|
await createSession(session);
|
|
16928
17606
|
const ssDir = screenshotsDir ?? `${process.env.HOME}/.hasna/computer/screenshots/${sessionId}`;
|
|
16929
17607
|
if (saveScreenshots) {
|
|
16930
|
-
await
|
|
17608
|
+
await mkdir2(ssDir, { recursive: true });
|
|
16931
17609
|
}
|
|
16932
17610
|
const history = [];
|
|
16933
17611
|
const startTime = Date.now();
|
|
@@ -17231,32 +17909,65 @@ class PgAdapterAsync {
|
|
|
17231
17909
|
}
|
|
17232
17910
|
}
|
|
17233
17911
|
|
|
17234
|
-
// src/db/
|
|
17235
|
-
var
|
|
17912
|
+
// src/db/storage-sync.ts
|
|
17913
|
+
var STORAGE_TABLES = ["sessions", "action_logs", "feedback"];
|
|
17914
|
+
var COMPUTER_STORAGE_ENV = "HASNA_COMPUTER_DATABASE_URL";
|
|
17915
|
+
var COMPUTER_STORAGE_FALLBACK_ENV = "COMPUTER_DATABASE_URL";
|
|
17916
|
+
var COMPUTER_STORAGE_MODE_ENV = "HASNA_COMPUTER_STORAGE_MODE";
|
|
17917
|
+
var COMPUTER_STORAGE_MODE_FALLBACK_ENV = "COMPUTER_STORAGE_MODE";
|
|
17918
|
+
var STORAGE_DATABASE_ENV = [COMPUTER_STORAGE_ENV, COMPUTER_STORAGE_FALLBACK_ENV];
|
|
17236
17919
|
var PRIMARY_KEYS = {
|
|
17237
17920
|
sessions: ["id"],
|
|
17238
17921
|
action_logs: ["id"],
|
|
17239
17922
|
feedback: ["id"]
|
|
17240
17923
|
};
|
|
17241
|
-
function
|
|
17242
|
-
|
|
17924
|
+
function readEnv3(name) {
|
|
17925
|
+
const value = process.env[name]?.trim();
|
|
17926
|
+
return value || undefined;
|
|
17927
|
+
}
|
|
17928
|
+
function normalizeStorageMode(value) {
|
|
17929
|
+
const normalized = value?.trim().toLowerCase();
|
|
17930
|
+
if (normalized === "local" || normalized === "hybrid" || normalized === "remote")
|
|
17931
|
+
return normalized;
|
|
17932
|
+
return;
|
|
17933
|
+
}
|
|
17934
|
+
function getStorageDatabaseEnvName() {
|
|
17935
|
+
for (const name of STORAGE_DATABASE_ENV) {
|
|
17936
|
+
if (readEnv3(name))
|
|
17937
|
+
return name;
|
|
17938
|
+
}
|
|
17939
|
+
return null;
|
|
17940
|
+
}
|
|
17941
|
+
function getStorageDatabaseEnv() {
|
|
17942
|
+
const name = getStorageDatabaseEnvName();
|
|
17943
|
+
return name ? { name } : null;
|
|
17944
|
+
}
|
|
17945
|
+
function getStorageDatabaseUrl() {
|
|
17946
|
+
const env = getStorageDatabaseEnv();
|
|
17947
|
+
return env ? readEnv3(env.name) ?? null : null;
|
|
17243
17948
|
}
|
|
17244
|
-
|
|
17245
|
-
const
|
|
17949
|
+
function getStorageMode() {
|
|
17950
|
+
const mode = normalizeStorageMode(readEnv3(COMPUTER_STORAGE_MODE_ENV) ?? readEnv3(COMPUTER_STORAGE_MODE_FALLBACK_ENV));
|
|
17951
|
+
if (mode)
|
|
17952
|
+
return mode;
|
|
17953
|
+
return getStorageDatabaseUrl() ? "hybrid" : "local";
|
|
17954
|
+
}
|
|
17955
|
+
async function getStoragePg() {
|
|
17956
|
+
const url = getStorageDatabaseUrl();
|
|
17246
17957
|
if (!url) {
|
|
17247
|
-
throw new Error("Missing
|
|
17958
|
+
throw new Error("Missing HASNA_COMPUTER_DATABASE_URL");
|
|
17248
17959
|
}
|
|
17249
17960
|
return new PgAdapterAsync(url);
|
|
17250
17961
|
}
|
|
17251
|
-
async function
|
|
17962
|
+
async function runStorageMigrations(remote) {
|
|
17252
17963
|
for (const sql of PG_MIGRATIONS)
|
|
17253
17964
|
await remote.run(sql);
|
|
17254
17965
|
}
|
|
17255
|
-
async function
|
|
17256
|
-
const remote = await
|
|
17966
|
+
async function storagePush(options) {
|
|
17967
|
+
const remote = await getStoragePg();
|
|
17257
17968
|
const db2 = getDb();
|
|
17258
17969
|
try {
|
|
17259
|
-
await
|
|
17970
|
+
await runStorageMigrations(remote);
|
|
17260
17971
|
const results = [];
|
|
17261
17972
|
for (const table of resolveTables(options?.tables))
|
|
17262
17973
|
results.push(await pushTable(db2, remote, table));
|
|
@@ -17266,11 +17977,11 @@ async function cloudPush(options) {
|
|
|
17266
17977
|
await remote.close();
|
|
17267
17978
|
}
|
|
17268
17979
|
}
|
|
17269
|
-
async function
|
|
17270
|
-
const remote = await
|
|
17980
|
+
async function storagePull(options) {
|
|
17981
|
+
const remote = await getStoragePg();
|
|
17271
17982
|
const db2 = getDb();
|
|
17272
17983
|
try {
|
|
17273
|
-
await
|
|
17984
|
+
await runStorageMigrations(remote);
|
|
17274
17985
|
const results = [];
|
|
17275
17986
|
for (const table of resolveTables(options?.tables))
|
|
17276
17987
|
results.push(await pullTable(remote, db2, table));
|
|
@@ -17280,9 +17991,9 @@ async function cloudPull(options) {
|
|
|
17280
17991
|
await remote.close();
|
|
17281
17992
|
}
|
|
17282
17993
|
}
|
|
17283
|
-
async function
|
|
17284
|
-
const pull = await
|
|
17285
|
-
const push2 = await
|
|
17994
|
+
async function storageSync(options) {
|
|
17995
|
+
const pull = await storagePull(options);
|
|
17996
|
+
const push2 = await storagePush(options);
|
|
17286
17997
|
return { pull, push: push2 };
|
|
17287
17998
|
}
|
|
17288
17999
|
function getSyncMetaAll() {
|
|
@@ -17290,10 +18001,22 @@ function getSyncMetaAll() {
|
|
|
17290
18001
|
ensureSyncMetaTable(db2);
|
|
17291
18002
|
return db2.prepare("SELECT table_name, last_synced_at, direction FROM _computer_sync_meta ORDER BY table_name, direction").all();
|
|
17292
18003
|
}
|
|
18004
|
+
function getStorageStatus() {
|
|
18005
|
+
const activeEnv = getStorageDatabaseEnv();
|
|
18006
|
+
return {
|
|
18007
|
+
configured: Boolean(activeEnv),
|
|
18008
|
+
mode: getStorageMode(),
|
|
18009
|
+
env: STORAGE_DATABASE_ENV,
|
|
18010
|
+
activeEnv: activeEnv?.name ?? null,
|
|
18011
|
+
service: "computer",
|
|
18012
|
+
tables: STORAGE_TABLES,
|
|
18013
|
+
sync: getSyncMetaAll()
|
|
18014
|
+
};
|
|
18015
|
+
}
|
|
17293
18016
|
function resolveTables(tables) {
|
|
17294
18017
|
if (!tables || tables.length === 0)
|
|
17295
|
-
return [...
|
|
17296
|
-
const allowed = new Set(
|
|
18018
|
+
return [...STORAGE_TABLES];
|
|
18019
|
+
const allowed = new Set(STORAGE_TABLES);
|
|
17297
18020
|
const requested = tables.map((table) => table.trim()).filter(Boolean);
|
|
17298
18021
|
const invalid = requested.filter((table) => !allowed.has(table));
|
|
17299
18022
|
if (invalid.length > 0)
|
|
@@ -17381,7 +18104,7 @@ function upsertSqlite(db2, table, columns, rows) {
|
|
|
17381
18104
|
}
|
|
17382
18105
|
function recordSyncMeta(db2, direction, results) {
|
|
17383
18106
|
ensureSyncMetaTable(db2);
|
|
17384
|
-
const
|
|
18107
|
+
const now2 = new Date().toISOString();
|
|
17385
18108
|
for (const result of results) {
|
|
17386
18109
|
if (result.errors.length > 0)
|
|
17387
18110
|
continue;
|
|
@@ -17389,7 +18112,7 @@ function recordSyncMeta(db2, direction, results) {
|
|
|
17389
18112
|
INSERT INTO _computer_sync_meta (table_name, last_synced_at, direction)
|
|
17390
18113
|
VALUES (?, ?, ?)
|
|
17391
18114
|
ON CONFLICT(table_name, direction) DO UPDATE SET last_synced_at = excluded.last_synced_at
|
|
17392
|
-
`).run(result.table,
|
|
18115
|
+
`).run(result.table, now2, direction);
|
|
17393
18116
|
}
|
|
17394
18117
|
}
|
|
17395
18118
|
function ensureSyncMetaTable(db2) {
|
|
@@ -17419,12 +18142,7 @@ function coerceForSqlite(value) {
|
|
|
17419
18142
|
return String(value);
|
|
17420
18143
|
}
|
|
17421
18144
|
|
|
17422
|
-
// src/cli/
|
|
17423
|
-
var CLOUD_ENV = [
|
|
17424
|
-
"HASNA_COMPUTER_CLOUD_DATABASE_URL",
|
|
17425
|
-
"OPEN_COMPUTER_CLOUD_DATABASE_URL",
|
|
17426
|
-
"COMPUTER_CLOUD_DATABASE_URL"
|
|
17427
|
-
];
|
|
18145
|
+
// src/cli/storage.ts
|
|
17428
18146
|
function parseTables(value) {
|
|
17429
18147
|
if (!value)
|
|
17430
18148
|
return;
|
|
@@ -17441,21 +18159,15 @@ function printResults(results, label) {
|
|
|
17441
18159
|
}
|
|
17442
18160
|
console.log(`Done. ${total} rows ${label}.`);
|
|
17443
18161
|
}
|
|
17444
|
-
function
|
|
17445
|
-
const
|
|
17446
|
-
|
|
17447
|
-
const info =
|
|
17448
|
-
configured: Boolean(getCloudDatabaseUrl()),
|
|
17449
|
-
env: CLOUD_ENV,
|
|
17450
|
-
service: "computer",
|
|
17451
|
-
tables: CLOUD_TABLES,
|
|
17452
|
-
sync: getSyncMetaAll()
|
|
17453
|
-
};
|
|
18162
|
+
function registerStorageCommands(program2) {
|
|
18163
|
+
const storageCmd = program2.command("storage").description("Storage sync commands");
|
|
18164
|
+
storageCmd.command("status").description("Show storage config and local sync state").option("--json", "Output as JSON").action((opts) => {
|
|
18165
|
+
const info = getStorageStatus();
|
|
17454
18166
|
if (opts.json) {
|
|
17455
18167
|
printJson(info);
|
|
17456
18168
|
return;
|
|
17457
18169
|
}
|
|
17458
|
-
console.log(`
|
|
18170
|
+
console.log(`Storage configured: ${info.configured ? "yes" : "no"}`);
|
|
17459
18171
|
console.log(`Tables: ${info.tables.join(", ")}`);
|
|
17460
18172
|
if (info.sync.length === 0)
|
|
17461
18173
|
console.log("Sync: no local sync history");
|
|
@@ -17463,9 +18175,9 @@ function registerCloudCommands(program2) {
|
|
|
17463
18175
|
console.log(` ${entry.table_name} ${entry.direction}: ${entry.last_synced_at ?? "never"}`);
|
|
17464
18176
|
}
|
|
17465
18177
|
});
|
|
17466
|
-
|
|
18178
|
+
storageCmd.command("push").description("Push local computer data to storage PostgreSQL").option("--tables <tables>", "Comma-separated table names (default: all)").option("--json", "Output as JSON").action(async (opts) => {
|
|
17467
18179
|
try {
|
|
17468
|
-
const results = await
|
|
18180
|
+
const results = await storagePush({ tables: parseTables(opts.tables) });
|
|
17469
18181
|
if (opts.json) {
|
|
17470
18182
|
printJson(results);
|
|
17471
18183
|
return;
|
|
@@ -17476,9 +18188,9 @@ function registerCloudCommands(program2) {
|
|
|
17476
18188
|
process.exit(1);
|
|
17477
18189
|
}
|
|
17478
18190
|
});
|
|
17479
|
-
|
|
18191
|
+
storageCmd.command("pull").description("Pull computer data from storage PostgreSQL to local SQLite").option("--tables <tables>", "Comma-separated table names (default: all)").option("--json", "Output as JSON").action(async (opts) => {
|
|
17480
18192
|
try {
|
|
17481
|
-
const results = await
|
|
18193
|
+
const results = await storagePull({ tables: parseTables(opts.tables) });
|
|
17482
18194
|
if (opts.json) {
|
|
17483
18195
|
printJson(results);
|
|
17484
18196
|
return;
|
|
@@ -17489,9 +18201,9 @@ function registerCloudCommands(program2) {
|
|
|
17489
18201
|
process.exit(1);
|
|
17490
18202
|
}
|
|
17491
18203
|
});
|
|
17492
|
-
|
|
18204
|
+
storageCmd.command("sync").description("Bidirectional sync: pull then push").option("--tables <tables>", "Comma-separated table names (default: all)").option("--json", "Output as JSON").action(async (opts) => {
|
|
17493
18205
|
try {
|
|
17494
|
-
const result = await
|
|
18206
|
+
const result = await storageSync({ tables: parseTables(opts.tables) });
|
|
17495
18207
|
if (opts.json) {
|
|
17496
18208
|
printJson(result);
|
|
17497
18209
|
return;
|
|
@@ -17560,6 +18272,188 @@ function renderKitty(base64, width, height) {
|
|
|
17560
18272
|
`;
|
|
17561
18273
|
}
|
|
17562
18274
|
|
|
18275
|
+
// src/apps/ghostty/driver.ts
|
|
18276
|
+
import { existsSync as existsSync4 } from "fs";
|
|
18277
|
+
|
|
18278
|
+
// src/apps/ghostty/applescript.ts
|
|
18279
|
+
function parseGrid(spec) {
|
|
18280
|
+
const m = /^(\d+)x(\d+)$/.exec(spec.trim());
|
|
18281
|
+
if (!m)
|
|
18282
|
+
throw new Error(`Invalid grid spec "${spec}" \u2014 expected RxC (e.g. 2x2, 3x1)`);
|
|
18283
|
+
const rows = parseInt(m[1], 10);
|
|
18284
|
+
const cols = parseInt(m[2], 10);
|
|
18285
|
+
if (rows < 1 || cols < 1) {
|
|
18286
|
+
throw new Error(`Invalid grid spec "${spec}" \u2014 rows and cols must be >= 1`);
|
|
18287
|
+
}
|
|
18288
|
+
return { rows, cols };
|
|
18289
|
+
}
|
|
18290
|
+
function parseTabsSpec(spec) {
|
|
18291
|
+
const parts = spec.split(",");
|
|
18292
|
+
if (parts.length === 0 || spec.trim() === "") {
|
|
18293
|
+
throw new Error(`Invalid tabs spec "${spec}" \u2014 expected comma-separated grids (e.g. "2x2,1x2")`);
|
|
18294
|
+
}
|
|
18295
|
+
return parts.map((p) => parseGrid(p));
|
|
18296
|
+
}
|
|
18297
|
+
function escapeAppleScript(s) {
|
|
18298
|
+
return s.replace(/\\/g, "\\\\").replace(/"/g, "\\\"");
|
|
18299
|
+
}
|
|
18300
|
+
function shellQuote(s) {
|
|
18301
|
+
return `'${s.replace(/'/g, `'\\''`)}'`;
|
|
18302
|
+
}
|
|
18303
|
+
function assignPaneCommands(totalPanes, run, all) {
|
|
18304
|
+
if (all) {
|
|
18305
|
+
if (run.length !== 1) {
|
|
18306
|
+
throw new Error(`--all requires exactly one --run command (got ${run.length})`);
|
|
18307
|
+
}
|
|
18308
|
+
return Array.from({ length: totalPanes }, () => run[0]);
|
|
18309
|
+
}
|
|
18310
|
+
if (run.length > totalPanes) {
|
|
18311
|
+
throw new Error(`Got ${run.length} commands for ${totalPanes} pane${totalPanes === 1 ? "" : "s"} \u2014 add panes or drop commands`);
|
|
18312
|
+
}
|
|
18313
|
+
return Array.from({ length: totalPanes }, (_, i) => run[i]);
|
|
18314
|
+
}
|
|
18315
|
+
function buildGhosttyScript(opts) {
|
|
18316
|
+
const { tabs, commands = [], dir, max = false } = opts;
|
|
18317
|
+
if (tabs.length === 0)
|
|
18318
|
+
throw new Error("At least one tab grid is required");
|
|
18319
|
+
const lines = [];
|
|
18320
|
+
lines.push('tell application "Ghostty"');
|
|
18321
|
+
lines.push(" activate");
|
|
18322
|
+
lines.push(" set w to new window");
|
|
18323
|
+
const term = (t, r, c) => `t${t}_${r}_${c}`;
|
|
18324
|
+
let paneIndex = 0;
|
|
18325
|
+
for (let t = 0;t < tabs.length; t++) {
|
|
18326
|
+
const { rows, cols } = tabs[t];
|
|
18327
|
+
if (t === 0) {
|
|
18328
|
+
lines.push(` set ${term(0, 0, 0)} to focused terminal of selected tab of w`);
|
|
18329
|
+
if (max) {
|
|
18330
|
+
lines.push(` perform action "toggle_maximize" on ${term(0, 0, 0)}`);
|
|
18331
|
+
}
|
|
18332
|
+
} else {
|
|
18333
|
+
lines.push(` set tb${t} to new tab in w`);
|
|
18334
|
+
lines.push(` set ${term(t, 0, 0)} to focused terminal of tb${t}`);
|
|
18335
|
+
}
|
|
18336
|
+
for (let r = 1;r < rows; r++) {
|
|
18337
|
+
lines.push(` set ${term(t, r, 0)} to split ${term(t, r - 1, 0)} direction down`);
|
|
18338
|
+
}
|
|
18339
|
+
for (let r = 0;r < rows; r++) {
|
|
18340
|
+
for (let c = 1;c < cols; c++) {
|
|
18341
|
+
lines.push(` set ${term(t, r, c)} to split ${term(t, r, c - 1)} direction right`);
|
|
18342
|
+
}
|
|
18343
|
+
}
|
|
18344
|
+
if (rows * cols > 1) {
|
|
18345
|
+
lines.push(` perform action "equalize_splits" on ${term(t, 0, 0)}`);
|
|
18346
|
+
}
|
|
18347
|
+
for (let r = 0;r < rows; r++) {
|
|
18348
|
+
for (let c = 0;c < cols; c++) {
|
|
18349
|
+
const raw = commands[paneIndex];
|
|
18350
|
+
paneIndex++;
|
|
18351
|
+
let cmd;
|
|
18352
|
+
if (dir && raw)
|
|
18353
|
+
cmd = `cd ${shellQuote(dir)} && ${raw}`;
|
|
18354
|
+
else if (dir)
|
|
18355
|
+
cmd = `cd ${shellQuote(dir)}`;
|
|
18356
|
+
else
|
|
18357
|
+
cmd = raw;
|
|
18358
|
+
if (cmd) {
|
|
18359
|
+
lines.push(` input text "${escapeAppleScript(cmd)}" to ${term(t, r, c)}`);
|
|
18360
|
+
lines.push(` send key "enter" to ${term(t, r, c)}`);
|
|
18361
|
+
}
|
|
18362
|
+
}
|
|
18363
|
+
}
|
|
18364
|
+
}
|
|
18365
|
+
lines.push(` focus ${term(0, 0, 0)}`);
|
|
18366
|
+
lines.push("end tell");
|
|
18367
|
+
return lines.join(`
|
|
18368
|
+
`) + `
|
|
18369
|
+
`;
|
|
18370
|
+
}
|
|
18371
|
+
|
|
18372
|
+
// src/apps/ghostty/driver.ts
|
|
18373
|
+
var APP_BUNDLE = "/Applications/Ghostty.app";
|
|
18374
|
+
function ghosttyAvailability(env = {}) {
|
|
18375
|
+
const platform = env.platform ?? process.platform;
|
|
18376
|
+
if (platform !== "darwin") {
|
|
18377
|
+
return { available: false, reason: "Ghostty orchestration requires macOS (AppleScript)" };
|
|
18378
|
+
}
|
|
18379
|
+
const hasAppBundle = env.hasAppBundle ?? existsSync4(APP_BUNDLE);
|
|
18380
|
+
const hasBinary = env.hasBinary ?? Bun.which("ghostty") !== null;
|
|
18381
|
+
if (!hasAppBundle && !hasBinary) {
|
|
18382
|
+
return { available: false, reason: `Ghostty not found (${APP_BUNDLE} missing and no \`ghostty\` on PATH)` };
|
|
18383
|
+
}
|
|
18384
|
+
return { available: true };
|
|
18385
|
+
}
|
|
18386
|
+
async function runOsascript(script) {
|
|
18387
|
+
const proc = Bun.spawn(["osascript", "-e", script], {
|
|
18388
|
+
stdout: "pipe",
|
|
18389
|
+
stderr: "pipe"
|
|
18390
|
+
});
|
|
18391
|
+
const code = await proc.exited;
|
|
18392
|
+
const stderr = await new Response(proc.stderr).text();
|
|
18393
|
+
return { ok: code === 0, stderr: stderr.trim() };
|
|
18394
|
+
}
|
|
18395
|
+
function resolveTabs(spec) {
|
|
18396
|
+
if (spec.tabs && spec.tabs.length > 0)
|
|
18397
|
+
return spec.tabs;
|
|
18398
|
+
return [spec.grid ?? { rows: 1, cols: 1 }];
|
|
18399
|
+
}
|
|
18400
|
+
var ghosttyDriver = {
|
|
18401
|
+
name: "ghostty",
|
|
18402
|
+
description: "Ghostty terminal \u2014 windows with pane grids, tabs, and a command per pane",
|
|
18403
|
+
available() {
|
|
18404
|
+
return ghosttyAvailability();
|
|
18405
|
+
},
|
|
18406
|
+
async open(spec) {
|
|
18407
|
+
const availability = ghosttyAvailability();
|
|
18408
|
+
if (!availability.available) {
|
|
18409
|
+
return { ok: false, message: availability.reason ?? "Ghostty is not available" };
|
|
18410
|
+
}
|
|
18411
|
+
const tabs = resolveTabs(spec);
|
|
18412
|
+
const totalPanes = tabs.reduce((sum, g) => sum + g.rows * g.cols, 0);
|
|
18413
|
+
let commands;
|
|
18414
|
+
try {
|
|
18415
|
+
commands = assignPaneCommands(totalPanes, spec.run ?? [], spec.all ?? false);
|
|
18416
|
+
} catch (err) {
|
|
18417
|
+
return { ok: false, message: err instanceof Error ? err.message : String(err) };
|
|
18418
|
+
}
|
|
18419
|
+
const script = buildGhosttyScript({
|
|
18420
|
+
tabs,
|
|
18421
|
+
commands,
|
|
18422
|
+
dir: spec.dir,
|
|
18423
|
+
max: spec.max
|
|
18424
|
+
});
|
|
18425
|
+
const result = await runOsascript(script);
|
|
18426
|
+
if (!result.ok) {
|
|
18427
|
+
return {
|
|
18428
|
+
ok: false,
|
|
18429
|
+
message: `osascript failed: ${result.stderr || "unknown error"}`,
|
|
18430
|
+
panes: totalPanes,
|
|
18431
|
+
tabs: tabs.length
|
|
18432
|
+
};
|
|
18433
|
+
}
|
|
18434
|
+
const tabDesc = tabs.map((g) => `${g.rows}x${g.cols}`).join(",");
|
|
18435
|
+
return {
|
|
18436
|
+
ok: true,
|
|
18437
|
+
message: `Opened Ghostty: ${tabs.length} tab${tabs.length === 1 ? "" : "s"} (${tabDesc}), ${totalPanes} pane${totalPanes === 1 ? "" : "s"}`,
|
|
18438
|
+
panes: totalPanes,
|
|
18439
|
+
tabs: tabs.length
|
|
18440
|
+
};
|
|
18441
|
+
}
|
|
18442
|
+
};
|
|
18443
|
+
|
|
18444
|
+
// src/apps/registry.ts
|
|
18445
|
+
var drivers = new Map;
|
|
18446
|
+
function registerAppDriver(driver) {
|
|
18447
|
+
drivers.set(driver.name.toLowerCase(), driver);
|
|
18448
|
+
}
|
|
18449
|
+
function getAppDriver(name) {
|
|
18450
|
+
return drivers.get(name.toLowerCase());
|
|
18451
|
+
}
|
|
18452
|
+
function listAppDrivers() {
|
|
18453
|
+
return [...drivers.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
18454
|
+
}
|
|
18455
|
+
registerAppDriver(ghosttyDriver);
|
|
18456
|
+
|
|
17563
18457
|
// src/cli/index.ts
|
|
17564
18458
|
var program2 = new Command;
|
|
17565
18459
|
program2.name("computer").description("Open-source computer use for AI agents \u2014 control your Mac with AI").version("0.1.0");
|
|
@@ -17626,6 +18520,60 @@ program2.command("screenshot").description("Take a screenshot of the current scr
|
|
|
17626
18520
|
console.log(chalk.dim(`Base64 length: ${ss.base64.length} chars`));
|
|
17627
18521
|
}
|
|
17628
18522
|
});
|
|
18523
|
+
var collectRun = (value, previous) => [...previous, value];
|
|
18524
|
+
program2.command("open").description("Open an app deterministically via its driver (no AI; see `computer apps`)").argument("<app>", "App to open (registered driver name, e.g. ghostty)").option("--grid <RxC>", 'Split the window into R rows x C cols (e.g. "2x2")').option("--tabs <specs>", 'Multiple tabs in one window, one grid spec each (e.g. "2x2,1x2,1x2")').option("--run <command>", "Command for the next pane in order (repeatable)", collectRun, []).option("--all", "Run the single --run command in every pane", false).option("--dir <path>", "Working directory \u2014 every pane cds here first").option("--max", "Maximize the new window (not native fullscreen)", false).action(async (app, opts) => {
|
|
18525
|
+
const driver = getAppDriver(app);
|
|
18526
|
+
if (!driver) {
|
|
18527
|
+
console.error(chalk.red(`No app driver registered for "${app}".`));
|
|
18528
|
+
console.error(chalk.dim("List available drivers with `computer apps`."));
|
|
18529
|
+
process.exit(1);
|
|
18530
|
+
}
|
|
18531
|
+
const availability = driver.available();
|
|
18532
|
+
if (!availability.available) {
|
|
18533
|
+
console.error(chalk.red(`${driver.name} is not available on this machine.`));
|
|
18534
|
+
if (availability.reason)
|
|
18535
|
+
console.error(chalk.dim(`Reason: ${availability.reason}`));
|
|
18536
|
+
process.exit(1);
|
|
18537
|
+
}
|
|
18538
|
+
let spec;
|
|
18539
|
+
try {
|
|
18540
|
+
spec = {
|
|
18541
|
+
grid: opts.grid ? parseGrid(opts.grid) : undefined,
|
|
18542
|
+
tabs: opts.tabs ? parseTabsSpec(opts.tabs) : undefined,
|
|
18543
|
+
run: opts.run,
|
|
18544
|
+
all: opts.all,
|
|
18545
|
+
dir: opts.dir,
|
|
18546
|
+
max: opts.max
|
|
18547
|
+
};
|
|
18548
|
+
} catch (err) {
|
|
18549
|
+
console.error(chalk.red(err instanceof Error ? err.message : String(err)));
|
|
18550
|
+
process.exit(1);
|
|
18551
|
+
}
|
|
18552
|
+
const result = await driver.open(spec);
|
|
18553
|
+
if (result.ok) {
|
|
18554
|
+
console.log(chalk.green(result.message));
|
|
18555
|
+
} else {
|
|
18556
|
+
console.error(chalk.red(result.message));
|
|
18557
|
+
process.exit(1);
|
|
18558
|
+
}
|
|
18559
|
+
});
|
|
18560
|
+
program2.command("apps").description("List registered app drivers and their availability on this machine").action(async () => {
|
|
18561
|
+
const drivers2 = listAppDrivers();
|
|
18562
|
+
if (drivers2.length === 0) {
|
|
18563
|
+
console.log(chalk.dim("No app drivers registered."));
|
|
18564
|
+
return;
|
|
18565
|
+
}
|
|
18566
|
+
console.log(chalk.bold(`App drivers
|
|
18567
|
+
`));
|
|
18568
|
+
for (const driver of drivers2) {
|
|
18569
|
+
const availability = driver.available();
|
|
18570
|
+
const status = availability.available ? chalk.green("available") : chalk.red("unavailable");
|
|
18571
|
+
console.log(` ${chalk.cyan(driver.name.padEnd(12))} ${status} ${chalk.dim(driver.description)}`);
|
|
18572
|
+
if (!availability.available && availability.reason) {
|
|
18573
|
+
console.log(chalk.dim(` ${" ".repeat(12)} ${availability.reason}`));
|
|
18574
|
+
}
|
|
18575
|
+
}
|
|
18576
|
+
});
|
|
17629
18577
|
program2.command("sessions").description("List computer use sessions").option("-n, --limit <n>", "Number of sessions to show", "20").option("--status <status>", "Filter by status").option("--tag <tag>", "Filter by tag").action(async (opts) => {
|
|
17630
18578
|
const sessions = listSessions({
|
|
17631
18579
|
limit: parseInt(opts.limit),
|
|
@@ -17878,16 +18826,16 @@ program2.command("headless").description("Check headless mode status and availab
|
|
|
17878
18826
|
console.log(status.recommendation);
|
|
17879
18827
|
});
|
|
17880
18828
|
program2.command("record").description("Record mouse/keyboard events as a replayable macro").option("-d, --duration <seconds>", "Max recording duration in seconds", "60").option("-o, --output <file>", "Save recording to JSON file").action(async (opts) => {
|
|
17881
|
-
const { join:
|
|
18829
|
+
const { join: join7, dirname: dirname2 } = await import("path");
|
|
17882
18830
|
const { fileURLToPath: fileURLToPath2 } = await import("url");
|
|
17883
|
-
const { existsSync:
|
|
18831
|
+
const { existsSync: existsSync5, writeFileSync: writeFileSync2 } = await import("fs");
|
|
17884
18832
|
const __dirname2 = dirname2(fileURLToPath2(import.meta.url));
|
|
17885
18833
|
const candidates = [
|
|
17886
|
-
|
|
17887
|
-
|
|
17888
|
-
|
|
18834
|
+
join7(__dirname2, "..", "..", "helpers", "record"),
|
|
18835
|
+
join7(__dirname2, "..", "helpers", "record"),
|
|
18836
|
+
join7(process.env.HOME ?? "~", ".hasna", "computer", "helpers", "record")
|
|
17889
18837
|
];
|
|
17890
|
-
const helperPath = candidates.find((c) =>
|
|
18838
|
+
const helperPath = candidates.find((c) => existsSync5(c));
|
|
17891
18839
|
if (!helperPath) {
|
|
17892
18840
|
console.log(chalk.red("Record helper not found. Run: swiftc helpers/record.swift -o helpers/record -framework CoreGraphics"));
|
|
17893
18841
|
process.exit(1);
|
|
@@ -17908,9 +18856,9 @@ program2.command("record").description("Record mouse/keyboard events as a replay
|
|
|
17908
18856
|
} else {
|
|
17909
18857
|
const dir = getDataDir("computer");
|
|
17910
18858
|
const filename = `recording-${Date.now()}.json`;
|
|
17911
|
-
const path =
|
|
18859
|
+
const path = join7(dir, "recordings", filename);
|
|
17912
18860
|
const { mkdirSync: mkdirSync3 } = await import("fs");
|
|
17913
|
-
mkdirSync3(
|
|
18861
|
+
mkdirSync3(join7(dir, "recordings"), { recursive: true });
|
|
17914
18862
|
writeFileSync2(path, stdout);
|
|
17915
18863
|
console.log(chalk.green(`Recording saved: ${path}`));
|
|
17916
18864
|
}
|
|
@@ -17926,7 +18874,8 @@ program2.command("completions").description("Generate shell completions").argume
|
|
|
17926
18874
|
process.exit(1);
|
|
17927
18875
|
}
|
|
17928
18876
|
});
|
|
17929
|
-
|
|
18877
|
+
registerStorageCommands(program2);
|
|
18878
|
+
registerEventsCommands(program2, { source: "computer" });
|
|
17930
18879
|
program2.parse();
|
|
17931
18880
|
function generateZshCompletions() {
|
|
17932
18881
|
return `#compdef computer
|
|
@@ -17937,6 +18886,8 @@ _computer() {
|
|
|
17937
18886
|
local -a commands
|
|
17938
18887
|
commands=(
|
|
17939
18888
|
'run:Run a computer use task'
|
|
18889
|
+
'open:Open an app via its driver'
|
|
18890
|
+
'apps:List app drivers'
|
|
17940
18891
|
'screenshot:Take a screenshot'
|
|
17941
18892
|
'sessions:List sessions'
|
|
17942
18893
|
'session:Show session details'
|
|
@@ -17946,7 +18897,7 @@ _computer() {
|
|
|
17946
18897
|
'search:Search sessions'
|
|
17947
18898
|
'config:View or modify configuration'
|
|
17948
18899
|
'completions:Generate shell completions'
|
|
17949
|
-
'
|
|
18900
|
+
'storage:Storage sync and feedback'
|
|
17950
18901
|
)
|
|
17951
18902
|
|
|
17952
18903
|
_arguments -C \\
|
|
@@ -17971,6 +18922,16 @@ _computer() {
|
|
|
17971
18922
|
'--no-preview[Disable inline preview]' \\
|
|
17972
18923
|
'1:task:'
|
|
17973
18924
|
;;
|
|
18925
|
+
open)
|
|
18926
|
+
_arguments \\
|
|
18927
|
+
'--grid[Pane grid RxC]:grid:' \\
|
|
18928
|
+
'--tabs[Tab grid specs]:tabs:' \\
|
|
18929
|
+
'*--run[Command per pane]:command:' \\
|
|
18930
|
+
'--all[Same command in every pane]' \\
|
|
18931
|
+
'--dir[Working directory]:dir:_files -/' \\
|
|
18932
|
+
'--max[Maximize window]' \\
|
|
18933
|
+
'1:app:(ghostty)'
|
|
18934
|
+
;;
|
|
17974
18935
|
sessions)
|
|
17975
18936
|
_arguments \\
|
|
17976
18937
|
'-n[Limit]:limit:' \\
|
|
@@ -18001,7 +18962,7 @@ _computer_completions() {
|
|
|
18001
18962
|
COMPREPLY=()
|
|
18002
18963
|
cur="\${COMP_WORDS[COMP_CWORD]}"
|
|
18003
18964
|
prev="\${COMP_WORDS[COMP_CWORD-1]}"
|
|
18004
|
-
commands="run screenshot sessions session delete stats watch search config completions
|
|
18965
|
+
commands="run open apps screenshot sessions session delete stats watch search config completions storage"
|
|
18005
18966
|
|
|
18006
18967
|
if [ "$COMP_CWORD" -eq 1 ]; then
|
|
18007
18968
|
COMPREPLY=( $(compgen -W "$commands" -- "$cur") )
|
|
@@ -18012,6 +18973,9 @@ _computer_completions() {
|
|
|
18012
18973
|
run)
|
|
18013
18974
|
COMPREPLY=( $(compgen -W "-p --provider -m --model -s --max-steps --save-screenshots --dry-run --tag --max-width --no-preview" -- "$cur") )
|
|
18014
18975
|
;;
|
|
18976
|
+
open)
|
|
18977
|
+
COMPREPLY=( $(compgen -W "ghostty --grid --tabs --run --all --dir --max" -- "$cur") )
|
|
18978
|
+
;;
|
|
18015
18979
|
sessions)
|
|
18016
18980
|
COMPREPLY=( $(compgen -W "-n --limit --status --tag" -- "$cur") )
|
|
18017
18981
|
;;
|