@hanamorilabs/tab 0.1.14 → 0.1.16
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/dist/cli.js +99 -23
- package/dist/codex-home.js +9 -0
- package/dist/console-api.js +4 -0
- package/dist/manage.js +42 -7
- package/dist/observability-events.js +80 -0
- package/dist/observability-hook.js +36 -0
- package/dist/observability-queue.js +194 -0
- package/dist/observability.js +249 -0
- package/dist/project.js +46 -4
- package/dist/proxy-bin.js +1 -1
- package/dist/statusline.js +114 -0
- package/dist/tab-docs-shared.js +9 -0
- package/dist/tab-docs.js +45 -16
- package/dist/version.js +1 -1
- package/package.json +6 -6
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { linkSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { readFile, readdir } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
export const QUEUE_LIMIT = 512;
|
|
6
|
+
const localLocks = new Map();
|
|
7
|
+
export class ObservationLockTimeout extends Error {
|
|
8
|
+
}
|
|
9
|
+
function readOwner(file) {
|
|
10
|
+
try {
|
|
11
|
+
const owner = JSON.parse(readFileSync(file, "utf8"));
|
|
12
|
+
return Number.isInteger(owner.pid) && owner.pid > 0 && typeof owner.token === "string" ? owner : undefined;
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
function alive(pid) {
|
|
19
|
+
try {
|
|
20
|
+
process.kill(pid, 0);
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
return error.code !== "ESRCH";
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function releaseOwned(file, token) {
|
|
28
|
+
if (readOwner(file)?.token === token)
|
|
29
|
+
rmSync(file, { force: true });
|
|
30
|
+
}
|
|
31
|
+
function reclaimDeadOwner(lock) {
|
|
32
|
+
// Serialize reapers. An unknown/legacy lock is never stolen based on age alone.
|
|
33
|
+
const reaper = `${lock}.reaping`;
|
|
34
|
+
try {
|
|
35
|
+
mkdirSync(reaper);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
try {
|
|
41
|
+
const owner = readOwner(lock);
|
|
42
|
+
if (owner && !alive(owner.pid))
|
|
43
|
+
releaseOwned(lock, owner.token);
|
|
44
|
+
}
|
|
45
|
+
finally {
|
|
46
|
+
rmSync(reaper, { recursive: true, force: true });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
export async function withObservationLock(dir, action) {
|
|
50
|
+
// Sibling calls wait their turn before starting the cross-process deadline.
|
|
51
|
+
const previous = localLocks.get(dir) ?? Promise.resolve();
|
|
52
|
+
let done;
|
|
53
|
+
const current = new Promise((resolve) => { done = resolve; });
|
|
54
|
+
localLocks.set(dir, current);
|
|
55
|
+
await previous;
|
|
56
|
+
const lock = path.join(dir, ".lock");
|
|
57
|
+
const token = randomUUID();
|
|
58
|
+
const candidate = path.join(dir, `.owner-${token}`);
|
|
59
|
+
let acquired = false;
|
|
60
|
+
try {
|
|
61
|
+
writeFileSync(candidate, JSON.stringify({ pid: process.pid, token }), { mode: 0o600, flag: "wx" });
|
|
62
|
+
const deadline = Date.now() + 750;
|
|
63
|
+
for (;;) {
|
|
64
|
+
try {
|
|
65
|
+
linkSync(candidate, lock);
|
|
66
|
+
acquired = true;
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
if (error.code !== "EEXIST")
|
|
71
|
+
throw error;
|
|
72
|
+
reclaimDeadOwner(lock);
|
|
73
|
+
if (Date.now() >= deadline)
|
|
74
|
+
throw new ObservationLockTimeout("Observability queue is busy");
|
|
75
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return await action();
|
|
79
|
+
}
|
|
80
|
+
finally {
|
|
81
|
+
try {
|
|
82
|
+
if (acquired)
|
|
83
|
+
releaseOwned(lock, token);
|
|
84
|
+
rmSync(candidate, { force: true });
|
|
85
|
+
}
|
|
86
|
+
finally {
|
|
87
|
+
done();
|
|
88
|
+
if (localLocks.get(dir) === current)
|
|
89
|
+
localLocks.delete(dir);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const isEventFile = (file) => /^[a-zA-Z0-9-]+\.json$/.test(file) && file !== "run.json";
|
|
94
|
+
const eventFiles = async (dir) => (await readdir(dir)).filter(isEventFile);
|
|
95
|
+
function contentionGap(dir) {
|
|
96
|
+
const gap = { id: randomUUID(), kind: "gap", sessionId: "collector", occurredAt: new Date().toISOString(), droppedCount: 1 };
|
|
97
|
+
// Coalesce a contention episode into one lower-bound gap; no unbounded side queue.
|
|
98
|
+
const candidate = path.join(dir, `.gap-${gap.id}`);
|
|
99
|
+
try {
|
|
100
|
+
writeFileSync(candidate, JSON.stringify(gap), { mode: 0o600, flag: "wx" });
|
|
101
|
+
try {
|
|
102
|
+
linkSync(candidate, path.join(dir, "contention-gap.json"));
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
if (error.code !== "EEXIST")
|
|
106
|
+
throw error;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
finally {
|
|
110
|
+
rmSync(candidate, { force: true });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
export async function enqueueEvent(dir, event, limit = QUEUE_LIMIT) {
|
|
114
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
115
|
+
try {
|
|
116
|
+
await withObservationLock(dir, () => {
|
|
117
|
+
const files = readdirSync(dir).filter(isEventFile);
|
|
118
|
+
const name = `${event.id}.json`;
|
|
119
|
+
if (files.includes(name))
|
|
120
|
+
return;
|
|
121
|
+
if (files.length >= limit) {
|
|
122
|
+
const file = path.join(dir, "dropped");
|
|
123
|
+
let dropped = 0;
|
|
124
|
+
try {
|
|
125
|
+
dropped = Number(readFileSync(file, "utf8")) || 0;
|
|
126
|
+
}
|
|
127
|
+
catch { /* First overflow. */ }
|
|
128
|
+
writeFileSync(file, String(Math.min(1_000_000, dropped + 1)), { mode: 0o600 });
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
const temp = path.join(dir, `${event.id}.tmp`);
|
|
132
|
+
writeFileSync(temp, JSON.stringify(event), { mode: 0o600 });
|
|
133
|
+
renameSync(temp, path.join(dir, name));
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
if (!(error instanceof ObservationLockTimeout))
|
|
138
|
+
throw error;
|
|
139
|
+
contentionGap(dir);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
/** Network runs outside the hook and lock. Files disappear only after acknowledgment. */
|
|
143
|
+
export async function flushQueue(dir, run, send) {
|
|
144
|
+
await withObservationLock(dir, () => {
|
|
145
|
+
const file = path.join(dir, "dropped");
|
|
146
|
+
let dropped = 0;
|
|
147
|
+
try {
|
|
148
|
+
dropped = Number(readFileSync(file, "utf8")) || 0;
|
|
149
|
+
}
|
|
150
|
+
catch { /* No overflow. */ }
|
|
151
|
+
if (dropped > 0 && !readdirSync(dir).includes("gap.json")) {
|
|
152
|
+
const gap = { id: randomUUID(), kind: "gap", sessionId: `collector:${run.id}`, occurredAt: new Date().toISOString(), droppedCount: Math.min(1_000_000, dropped) };
|
|
153
|
+
writeFileSync(path.join(dir, "gap.json"), JSON.stringify(gap), { mode: 0o600 });
|
|
154
|
+
rmSync(file, { force: true });
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
const files = (await eventFiles(dir)).sort().slice(0, 100);
|
|
158
|
+
const events = [];
|
|
159
|
+
const included = [];
|
|
160
|
+
for (const file of files) {
|
|
161
|
+
const text = await readFile(path.join(dir, file), "utf8").catch(() => "");
|
|
162
|
+
if (!text || Buffer.byteLength(text) > 4096)
|
|
163
|
+
continue;
|
|
164
|
+
try {
|
|
165
|
+
const event = JSON.parse(text);
|
|
166
|
+
if (Buffer.byteLength(JSON.stringify({ version: 1, run, events: [...events, event] })) > 120_000)
|
|
167
|
+
break;
|
|
168
|
+
events.push(event);
|
|
169
|
+
included.push({ file, id: event.id });
|
|
170
|
+
}
|
|
171
|
+
catch { /* Partial files are not acknowledged. */ }
|
|
172
|
+
}
|
|
173
|
+
// Registration accompanies the first event batch; idle or untrusted hooks send nothing.
|
|
174
|
+
if (events.length === 0)
|
|
175
|
+
return true;
|
|
176
|
+
if (!(await send({ version: 1, run, events }).catch(() => false)))
|
|
177
|
+
return false;
|
|
178
|
+
await withObservationLock(dir, () => {
|
|
179
|
+
// Other uploaders can replace reusable gap filenames while this request is in flight.
|
|
180
|
+
for (const { file, id } of included) {
|
|
181
|
+
const target = path.join(dir, file);
|
|
182
|
+
let saved;
|
|
183
|
+
try {
|
|
184
|
+
saved = JSON.parse(readFileSync(target, "utf8"));
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
if (saved.id === id)
|
|
190
|
+
rmSync(target, { force: true });
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
return true;
|
|
194
|
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { link, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { CODEX_SEND_TOOLS, identifier } from "./observability-events.js";
|
|
7
|
+
import { flushQueue, withObservationLock } from "./observability-queue.js";
|
|
8
|
+
const quote = (value) => `'${value.replaceAll("'", "'\\''")}'`;
|
|
9
|
+
async function isActive(dir) {
|
|
10
|
+
const pid = Number(await readFile(path.join(dir, "owner.pid"), "utf8").catch(() => "0"));
|
|
11
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
12
|
+
return false;
|
|
13
|
+
try {
|
|
14
|
+
process.kill(pid, 0);
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
async function queueIsEmpty(dir) {
|
|
22
|
+
const files = await readdir(dir).catch((error) => { if (error.code === "ENOENT")
|
|
23
|
+
return []; throw error; });
|
|
24
|
+
return !files.some((f) => (f.endsWith(".json") && f !== "run.json") || f === "dropped");
|
|
25
|
+
}
|
|
26
|
+
export function observationHooks(harness, command) {
|
|
27
|
+
const group = (matcher) => [{ ...(matcher ? { matcher } : {}), hooks: [{ type: "command", command, timeout: 2 }] }];
|
|
28
|
+
const hooks = {
|
|
29
|
+
SessionStart: group(), SessionEnd: group(), SubagentStart: group(), SubagentStop: group(),
|
|
30
|
+
PostToolUse: group(harness === "claude" ? "^(SendMessage|SubagentHandback)$" : `^(${CODEX_SEND_TOOLS.map((s) => s.replaceAll(".", "\\.")).join("|")})$`),
|
|
31
|
+
};
|
|
32
|
+
if (harness === "claude")
|
|
33
|
+
hooks.PostToolUseFailure = group("^(SendMessage|SubagentHandback)$");
|
|
34
|
+
return hooks;
|
|
35
|
+
}
|
|
36
|
+
function toml(value) {
|
|
37
|
+
if (Array.isArray(value))
|
|
38
|
+
return `[${value.map(toml).join(",")}]`;
|
|
39
|
+
if (value !== null && typeof value === "object")
|
|
40
|
+
return `{${Object.entries(value).map(([k, v]) => `${JSON.stringify(k)}=${toml(v)}`).join(",")}}`;
|
|
41
|
+
return JSON.stringify(value);
|
|
42
|
+
}
|
|
43
|
+
/** Session config is a separate hook source: Codex merges hook sources across layers. */
|
|
44
|
+
export function codexObservationArgs(hooks) {
|
|
45
|
+
return Object.entries(hooks).flatMap(([event, groups]) => ["-c", `hooks.${event}=${toml(groups)}`]);
|
|
46
|
+
}
|
|
47
|
+
async function installedVersion(command, env) {
|
|
48
|
+
return new Promise((resolve) => execFile(command, ["--version"], { env, timeout: 3000, maxBuffer: 4096 }, (error, stdout) => resolve(error ? "unknown" : (stdout.match(/\b\d+\.\d+\.\d+\b/)?.[0] ?? "unknown"))));
|
|
49
|
+
}
|
|
50
|
+
export function supportedObservationVersion(harness, version) {
|
|
51
|
+
// Explicitly tested schema families; future minor lines need a compatibility review.
|
|
52
|
+
const [major, minor, patch] = version.split(".").map(Number);
|
|
53
|
+
return harness === "claude" ? major === 2 && minor === 1 && patch >= 278 : major === 0 && minor === 155 && patch >= 1;
|
|
54
|
+
}
|
|
55
|
+
async function installationId(root) {
|
|
56
|
+
const file = path.join(root, "installation-id");
|
|
57
|
+
const temp = path.join(root, `.installation-${randomUUID()}`);
|
|
58
|
+
await writeFile(temp, randomUUID(), { flag: "wx", mode: 0o600 });
|
|
59
|
+
try {
|
|
60
|
+
// A hard link publishes a complete file without replacing a concurrent winner.
|
|
61
|
+
await link(temp, file).catch((error) => { if (error.code !== "EEXIST")
|
|
62
|
+
throw error; });
|
|
63
|
+
}
|
|
64
|
+
finally {
|
|
65
|
+
await rm(temp, { force: true });
|
|
66
|
+
}
|
|
67
|
+
const saved = await readFile(file, "utf8");
|
|
68
|
+
if (!/^[a-f0-9-]{36}$/.test(saved))
|
|
69
|
+
throw new Error("Invalid observability installation identity");
|
|
70
|
+
return saved;
|
|
71
|
+
}
|
|
72
|
+
const publishedRunCount = async (runs) => (await readdir(runs)).filter((name) => /^[a-f0-9-]{36}$/.test(name)).length;
|
|
73
|
+
/** At capacity, try other queues without making an observed launch wait for a serial backlog. */
|
|
74
|
+
export async function recoverObservationCapacity(input) {
|
|
75
|
+
const controller = new AbortController();
|
|
76
|
+
const pending = [...input.oldRuns];
|
|
77
|
+
let available = false;
|
|
78
|
+
const expired = new Promise((resolve) => controller.signal.addEventListener("abort", () => resolve(), { once: true }));
|
|
79
|
+
const timer = setTimeout(() => controller.abort(), input.timeoutMs ?? 2000);
|
|
80
|
+
const recover = async () => {
|
|
81
|
+
while (!controller.signal.aborted && !available) {
|
|
82
|
+
const old = pending.shift();
|
|
83
|
+
if (!old)
|
|
84
|
+
return;
|
|
85
|
+
try {
|
|
86
|
+
const ok = await flushQueue(old.dir, old.run, (batch) => input.send(batch, controller.signal));
|
|
87
|
+
if (controller.signal.aborted)
|
|
88
|
+
return;
|
|
89
|
+
if (ok) {
|
|
90
|
+
if (await queueIsEmpty(old.dir))
|
|
91
|
+
await rm(old.dir, { recursive: true, force: true });
|
|
92
|
+
// One batch may not empty a queue. Retry successful progress within the same deadline.
|
|
93
|
+
else
|
|
94
|
+
pending.push(old);
|
|
95
|
+
}
|
|
96
|
+
if (await publishedRunCount(input.runs) < (input.limit ?? 32))
|
|
97
|
+
available = true;
|
|
98
|
+
}
|
|
99
|
+
catch { /* Keep unacknowledged metadata; another queue may be recoverable. */ }
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
try {
|
|
103
|
+
await Promise.race([Promise.all([recover(), recover()]), expired]);
|
|
104
|
+
}
|
|
105
|
+
finally {
|
|
106
|
+
clearTimeout(timer);
|
|
107
|
+
controller.abort();
|
|
108
|
+
}
|
|
109
|
+
return available;
|
|
110
|
+
}
|
|
111
|
+
/** Independent deadlines keep a failed backlog from dominating current collection or its peers. */
|
|
112
|
+
export function createObservationUploader(input) {
|
|
113
|
+
const now = input.now ?? Date.now;
|
|
114
|
+
const oldRuns = input.oldRuns.map((old) => ({ ...old, failures: 0, retryAt: 0 }));
|
|
115
|
+
let active;
|
|
116
|
+
let failures = 0;
|
|
117
|
+
let retryAt = 0;
|
|
118
|
+
const flush = (forceCurrent = false) => {
|
|
119
|
+
if (active)
|
|
120
|
+
return active;
|
|
121
|
+
active = (async () => {
|
|
122
|
+
if (forceCurrent || now() >= retryAt) {
|
|
123
|
+
const ok = await flushQueue(input.current.dir, input.current.run, input.send).catch(() => false);
|
|
124
|
+
failures = ok ? 0 : Math.min(failures + 1, 5);
|
|
125
|
+
retryAt = now() + 2000 * 2 ** failures;
|
|
126
|
+
}
|
|
127
|
+
const index = oldRuns.findIndex((old) => now() >= old.retryAt);
|
|
128
|
+
if (index < 0)
|
|
129
|
+
return;
|
|
130
|
+
const [old] = oldRuns.splice(index, 1);
|
|
131
|
+
if (!old)
|
|
132
|
+
return;
|
|
133
|
+
let ok = false;
|
|
134
|
+
let complete = false;
|
|
135
|
+
try {
|
|
136
|
+
ok = await flushQueue(old.dir, old.run, input.send).catch((error) => error.code === "ENOENT");
|
|
137
|
+
if (ok && await queueIsEmpty(old.dir)) {
|
|
138
|
+
await rm(old.dir, { recursive: true, force: true });
|
|
139
|
+
complete = true;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
finally {
|
|
143
|
+
if (!complete) {
|
|
144
|
+
old.failures = ok ? 0 : Math.min(old.failures + 1, 5);
|
|
145
|
+
old.retryAt = now() + 2000 * 2 ** old.failures;
|
|
146
|
+
oldRuns.push(old);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
})().catch(() => undefined).finally(() => { active = undefined; });
|
|
150
|
+
return active;
|
|
151
|
+
};
|
|
152
|
+
return { flush, finish: async () => { await active; await flush(true); } };
|
|
153
|
+
}
|
|
154
|
+
/** Called only after explicit --observe. Writes only beneath tab's private directory. */
|
|
155
|
+
export async function prepareObservation(input) {
|
|
156
|
+
if (!identifier(input.agentId))
|
|
157
|
+
throw new Error("Observability needs a named Agent; run tab use. Continuing without collection.");
|
|
158
|
+
if (!input.config.token || !input.config.consoleUrl)
|
|
159
|
+
throw new Error("Observability needs tab login (machine session)");
|
|
160
|
+
if (process.platform === "win32")
|
|
161
|
+
throw new Error("Observability hooks currently support macOS and Linux");
|
|
162
|
+
const version = await installedVersion(input.command, input.env);
|
|
163
|
+
if (!supportedObservationVersion(input.harness, version))
|
|
164
|
+
throw new Error(`Observability does not support ${input.harness} ${version}; tested families: Claude 2.1.278+, Codex 0.155.1+`);
|
|
165
|
+
const root = path.join(input.root, "observability");
|
|
166
|
+
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
167
|
+
const runs = path.join(root, "runs");
|
|
168
|
+
await mkdir(runs, { recursive: true, mode: 0o700 });
|
|
169
|
+
// Never grow indefinitely when offline. Only expired, inactive collector-owned runs are removed.
|
|
170
|
+
const oldRuns = [];
|
|
171
|
+
for (const name of await readdir(runs)) {
|
|
172
|
+
const staging = /^\.initializing-[a-f0-9-]{36}$/.test(name);
|
|
173
|
+
if (!staging && !/^[a-f0-9-]{36}$/.test(name))
|
|
174
|
+
continue;
|
|
175
|
+
const dir = path.join(runs, name);
|
|
176
|
+
if (await isActive(dir))
|
|
177
|
+
continue;
|
|
178
|
+
const age = await stat(dir).then((s) => Date.now() - s.mtimeMs, () => 0);
|
|
179
|
+
if (staging) {
|
|
180
|
+
if (age > 7 * 86400_000)
|
|
181
|
+
await rm(dir, { recursive: true, force: true });
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
// Complete published runs can recover immediately. Only unknown/empty directories need grace.
|
|
185
|
+
if (age > 7 * 86400_000 || (age >= 60_000 && await queueIsEmpty(dir))) {
|
|
186
|
+
await rm(dir, { recursive: true, force: true });
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
try {
|
|
190
|
+
const run = JSON.parse(await readFile(path.join(dir, "run.json"), "utf8"));
|
|
191
|
+
if (run.agentId === input.agentId)
|
|
192
|
+
oldRuns.push({ dir, run });
|
|
193
|
+
}
|
|
194
|
+
catch { /* A concurrent launch may still be writing its run metadata. */ }
|
|
195
|
+
}
|
|
196
|
+
const url = `${input.config.consoleUrl.replace(/\/$/, "")}/api/cli/observability`;
|
|
197
|
+
const send = async (body, signal) => {
|
|
198
|
+
const timeout = AbortSignal.timeout(2000);
|
|
199
|
+
const response = await fetch(url, { method: "POST", headers: { authorization: `Bearer ${input.config.token}`, "content-type": "application/json" }, body: JSON.stringify(body), signal: signal ? AbortSignal.any([timeout, signal]) : timeout, redirect: "error" });
|
|
200
|
+
await response.body?.cancel();
|
|
201
|
+
return response.ok;
|
|
202
|
+
};
|
|
203
|
+
const runCount = () => publishedRunCount(runs);
|
|
204
|
+
if (await runCount() >= 32) {
|
|
205
|
+
const recovered = await recoverObservationCapacity({ runs, oldRuns, send });
|
|
206
|
+
if (!recovered && await runCount() >= 32)
|
|
207
|
+
throw new Error("Observability queue is full; retry when the console is reachable");
|
|
208
|
+
}
|
|
209
|
+
const run = { id: randomUUID(), agentId: input.agentId, installationId: await installationId(root), harness: input.harness, harnessVersion: version };
|
|
210
|
+
const dir = path.join(runs, run.id);
|
|
211
|
+
// Cleanup only considers UUID directories. Publish after ownership and plugin files are complete.
|
|
212
|
+
const staging = path.join(runs, `.initializing-${run.id}`);
|
|
213
|
+
await mkdir(staging, { mode: 0o700 });
|
|
214
|
+
const script = fileURLToPath(new URL(import.meta.url.endsWith(".ts") ? "./observability-hook.ts" : "./observability-hook.js", import.meta.url));
|
|
215
|
+
const command = `${quote(process.execPath)} ${quote(script)}`;
|
|
216
|
+
const hooks = observationHooks(input.harness, command);
|
|
217
|
+
let args = codexObservationArgs(hooks);
|
|
218
|
+
try {
|
|
219
|
+
await writeFile(path.join(staging, "run.json"), JSON.stringify(run), { mode: 0o600 });
|
|
220
|
+
await writeFile(path.join(staging, "owner.pid"), String(process.pid), { mode: 0o600 });
|
|
221
|
+
if (input.harness === "claude") {
|
|
222
|
+
const plugin = path.join(staging, "claude-plugin");
|
|
223
|
+
await mkdir(path.join(plugin, ".claude-plugin"), { recursive: true, mode: 0o700 });
|
|
224
|
+
await mkdir(path.join(plugin, "hooks"), { recursive: true, mode: 0o700 });
|
|
225
|
+
await writeFile(path.join(plugin, ".claude-plugin", "plugin.json"), JSON.stringify({ name: "flocktab-observability", version: "1.0.0" }), { mode: 0o600 });
|
|
226
|
+
await writeFile(path.join(plugin, "hooks", "hooks.json"), JSON.stringify({ hooks }), { mode: 0o600 });
|
|
227
|
+
args = ["--plugin-dir", path.join(dir, "claude-plugin")];
|
|
228
|
+
}
|
|
229
|
+
await withObservationLock(runs, async () => {
|
|
230
|
+
if (await runCount() >= 32)
|
|
231
|
+
throw new Error("Observability queue is full; retry when the console is reachable");
|
|
232
|
+
await rename(staging, dir);
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
finally {
|
|
236
|
+
await rm(staging, { recursive: true, force: true });
|
|
237
|
+
}
|
|
238
|
+
const uploader = createObservationUploader({ current: { dir, run }, oldRuns, send });
|
|
239
|
+
void uploader.flush();
|
|
240
|
+
const timer = setInterval(() => { void uploader.flush(); }, 2000);
|
|
241
|
+
timer.unref();
|
|
242
|
+
return { args, env: { FLOCKTAB_OBSERVE_DIR: dir, FLOCKTAB_OBSERVE_HARNESS: input.harness }, stop: async () => {
|
|
243
|
+
clearInterval(timer);
|
|
244
|
+
await uploader.finish();
|
|
245
|
+
await rm(path.join(dir, "owner.pid"), { force: true });
|
|
246
|
+
if (await queueIsEmpty(dir))
|
|
247
|
+
await rm(dir, { recursive: true, force: true });
|
|
248
|
+
} };
|
|
249
|
+
}
|
package/dist/project.js
CHANGED
|
@@ -16,6 +16,7 @@ export function projectFileName(env = process.env) {
|
|
|
16
16
|
const name = env.FLOCKTAB_PROJECT_FILE?.trim();
|
|
17
17
|
return name && /^\.[A-Za-z0-9._-]{1,40}$/.test(name) ? name : PROJECT_FILE;
|
|
18
18
|
}
|
|
19
|
+
const SLUG = /^[a-z0-9][a-z0-9-]*$/;
|
|
19
20
|
async function exists(file) {
|
|
20
21
|
try {
|
|
21
22
|
await access(file);
|
|
@@ -40,19 +41,49 @@ export async function findProjectFile(cwd) {
|
|
|
40
41
|
dir = parent;
|
|
41
42
|
}
|
|
42
43
|
}
|
|
43
|
-
|
|
44
|
+
/** The whole file, cleaned: only valid slugs survive. */
|
|
45
|
+
export async function readProjectConfig(cwd) {
|
|
44
46
|
const file = await findProjectFile(cwd);
|
|
45
47
|
if (!file)
|
|
46
48
|
return undefined;
|
|
47
49
|
try {
|
|
48
50
|
const parsed = JSON.parse(await readFile(file, "utf8"));
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
51
|
+
if (!parsed || typeof parsed !== "object")
|
|
52
|
+
return { file, config: {} };
|
|
53
|
+
const raw = parsed;
|
|
54
|
+
const config = {};
|
|
55
|
+
if (typeof raw.agent === "string" && SLUG.test(raw.agent))
|
|
56
|
+
config.agent = raw.agent;
|
|
57
|
+
if (raw.agents && typeof raw.agents === "object") {
|
|
58
|
+
const agents = {};
|
|
59
|
+
for (const [harness, slug] of Object.entries(raw.agents)) {
|
|
60
|
+
if (typeof slug === "string" && SLUG.test(slug) && /^[a-z]{1,16}$/.test(harness))
|
|
61
|
+
agents[harness] = slug;
|
|
62
|
+
}
|
|
63
|
+
if (Object.keys(agents).length > 0)
|
|
64
|
+
config.agents = agents;
|
|
65
|
+
}
|
|
66
|
+
return { file, config };
|
|
52
67
|
}
|
|
53
68
|
catch {
|
|
54
69
|
// Unreadable: treat as absent and ask again.
|
|
70
|
+
return undefined;
|
|
55
71
|
}
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* The Agent this folder runs `harness` as: its own entry, else the file's
|
|
75
|
+
* single `agent` (an older file, or a folder that made one Agent for
|
|
76
|
+
* everything before harnesses were told apart).
|
|
77
|
+
*/
|
|
78
|
+
export async function readProject(cwd, harness = "any") {
|
|
79
|
+
const found = await readProjectConfig(cwd);
|
|
80
|
+
if (!found)
|
|
81
|
+
return undefined;
|
|
82
|
+
const own = found.config.agents?.[harness];
|
|
83
|
+
if (own)
|
|
84
|
+
return { file: found.file, agent: own, own: true };
|
|
85
|
+
if (found.config.agent)
|
|
86
|
+
return { file: found.file, agent: found.config.agent, own: false };
|
|
56
87
|
return undefined;
|
|
57
88
|
}
|
|
58
89
|
/** Where a new `.flocktab` goes: the git root above `cwd` when there is one, else `cwd`. */
|
|
@@ -72,6 +103,17 @@ export async function writeProject(dir, config) {
|
|
|
72
103
|
await writeFile(file, `${JSON.stringify(config)}\n`);
|
|
73
104
|
return file;
|
|
74
105
|
}
|
|
106
|
+
/** Set which Agent this folder runs `harness` as, keeping the rest of the file. */
|
|
107
|
+
export async function assignProjectAgent(dir, harness, slug) {
|
|
108
|
+
const existing = (await readProjectConfig(dir))?.config ?? {};
|
|
109
|
+
const agents = { ...(existing.agents ?? {}) };
|
|
110
|
+
if (harness === "any") {
|
|
111
|
+
// The folder's default: also what older files meant by `agent`.
|
|
112
|
+
return writeProject(dir, { agent: slug, ...(Object.keys(agents).length > 0 ? { agents } : {}) });
|
|
113
|
+
}
|
|
114
|
+
agents[harness] = slug;
|
|
115
|
+
return writeProject(dir, { ...(existing.agent ? { agent: existing.agent } : {}), agents });
|
|
116
|
+
}
|
|
75
117
|
/** A default Agent name for a folder: its basename, kebab-cased. */
|
|
76
118
|
export function agentNameFor(dir) {
|
|
77
119
|
return path
|
package/dist/proxy-bin.js
CHANGED
|
@@ -13,7 +13,7 @@ import { createRequire } from "node:module";
|
|
|
13
13
|
import path from "node:path";
|
|
14
14
|
import { configDir } from "./config.js";
|
|
15
15
|
/** The proxy release `tab up` fetches. Bump with each proxy tag. */
|
|
16
|
-
export const PROXY_VERSION = "0.1.
|
|
16
|
+
export const PROXY_VERSION = "0.1.12";
|
|
17
17
|
export const RELEASES = "https://github.com/joseairosa/flocktab/releases/download";
|
|
18
18
|
export function targetFor(platform = process.platform, arch = process.arch) {
|
|
19
19
|
if (platform === "darwin")
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `tab statusline`: one line for a harness's status bar. Which Agent this
|
|
3
|
+
* folder runs the harness as, and either the tab (spent of cap) or, on a
|
|
4
|
+
* subscription, the plan's windows as the vendor last reported them. Read
|
|
5
|
+
* from the console with a short cache, so a status bar that polls every
|
|
6
|
+
* few seconds costs one request a minute.
|
|
7
|
+
*/
|
|
8
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { configDir } from "./config.js";
|
|
11
|
+
import { readProject } from "./project.js";
|
|
12
|
+
import { api } from "./manage.js";
|
|
13
|
+
const CACHE_MS = 60_000;
|
|
14
|
+
function windowMinutes(window) {
|
|
15
|
+
const m = /^(\d+)([mhd])$/.exec(window);
|
|
16
|
+
return m ? Number(m[1]) * (m[2] === "d" ? 1440 : m[2] === "h" ? 60 : 1) : Number.MAX_SAFE_INTEGER;
|
|
17
|
+
}
|
|
18
|
+
function money(cents) {
|
|
19
|
+
const n = BigInt(cents);
|
|
20
|
+
return `$${n / 100n}.${(n % 100n).toString().padStart(2, "0")}`;
|
|
21
|
+
}
|
|
22
|
+
/** "5h 36% · 7d 29%", shortest window first; nothing when the vendor said nothing yet. */
|
|
23
|
+
export function planLine(accounts, now = new Date()) {
|
|
24
|
+
// The account most recently seen with quota: the login in use.
|
|
25
|
+
const withQuota = accounts.filter((a) => a.quota.length > 0);
|
|
26
|
+
const account = withQuota[0];
|
|
27
|
+
if (!account)
|
|
28
|
+
return undefined;
|
|
29
|
+
const windows = [...account.quota]
|
|
30
|
+
.filter((w) => !w.resetsAt || new Date(w.resetsAt).getTime() > now.getTime())
|
|
31
|
+
.sort((a, b) => windowMinutes(a.window) - windowMinutes(b.window));
|
|
32
|
+
const who = account.email ?? `account ${account.externalId.slice(0, 8)}`;
|
|
33
|
+
return `${who} · ${windows.length === 0 ? "windows reset" : windows.map((w) => `${w.window} ${Math.round(w.usedPct)}%`).join(" · ")}`;
|
|
34
|
+
}
|
|
35
|
+
/** The line itself, from what the console said. */
|
|
36
|
+
export function statusText(info, now = new Date()) {
|
|
37
|
+
const { tab } = info;
|
|
38
|
+
const closed = tab.state !== "open";
|
|
39
|
+
const head = `${tab.name}${closed ? " CLOSED" : ""}`;
|
|
40
|
+
if (tab.kind === "subscription") {
|
|
41
|
+
return `${head} · ${planLine(info.accounts ?? [], now) ?? "no call yet"}`;
|
|
42
|
+
}
|
|
43
|
+
const spent = BigInt(tab.spentCents);
|
|
44
|
+
const cap = BigInt(tab.capCents);
|
|
45
|
+
const pct = cap > 0n ? Number((spent * 100n) / cap) : 0;
|
|
46
|
+
return `${head} · ${money(tab.spentCents)} of ${money(tab.capCents)} / ${tab.window} (${pct}%)`;
|
|
47
|
+
}
|
|
48
|
+
/** The tab of this folder's Agent for `harness`, from a one-minute cache, else the console. */
|
|
49
|
+
export async function statusline(config, harness, cwd = process.cwd(), env = process.env) {
|
|
50
|
+
const project = await readProject(cwd, harness);
|
|
51
|
+
if (!project)
|
|
52
|
+
return "FlockTab · no Agent here (tab use)";
|
|
53
|
+
const login = config.agents?.[project.agent];
|
|
54
|
+
const cacheFile = path.join(configDir(env), "cache", `status-${project.agent}.json`);
|
|
55
|
+
try {
|
|
56
|
+
const cached = JSON.parse(await readFile(cacheFile, "utf8"));
|
|
57
|
+
if (Date.now() - cached.at < CACHE_MS)
|
|
58
|
+
return cached.text;
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
// No cache yet.
|
|
62
|
+
}
|
|
63
|
+
let text;
|
|
64
|
+
try {
|
|
65
|
+
const info = await api(config)("GET", `/api/cli/tabs/${encodeURIComponent(project.agent)}`);
|
|
66
|
+
text = `FlockTab · ${statusText(info)}`;
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return `FlockTab · ${login?.agentName ?? project.agent} · console unreachable`;
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
await mkdir(path.dirname(cacheFile), { recursive: true, mode: 0o700 });
|
|
73
|
+
await writeFile(cacheFile, JSON.stringify({ at: Date.now(), text }), { mode: 0o600 });
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
// A missing cache only costs a request.
|
|
77
|
+
}
|
|
78
|
+
return text;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* How each harness is told to run `tab statusline`. Claude Code takes it on
|
|
82
|
+
* the command line, so every `tab claude` has it. Grok Build and Kimi Code
|
|
83
|
+
* read it only from a config file in their home: written into a pool
|
|
84
|
+
* member's home (which tab owns), and into the person's own home only by
|
|
85
|
+
* `tab statusline install`, which asks.
|
|
86
|
+
*/
|
|
87
|
+
export function claudeSettingsArg(harness, tabBin = "tab") {
|
|
88
|
+
return ["--settings", JSON.stringify({ statusLine: { type: "command", command: `${tabBin} statusline ${harness}`, padding: 0 } })];
|
|
89
|
+
}
|
|
90
|
+
/** The lines Grok Build's config.toml needs; appended when `[ui.status_line]` is absent. */
|
|
91
|
+
export function grokStatusToml(tabBin = "tab") {
|
|
92
|
+
return `\n[ui.status_line]\ntype = "command"\ncommand = "${tabBin} statusline grok"\n`;
|
|
93
|
+
}
|
|
94
|
+
/** Kimi Code's tui.toml block; appended when `[status_line]` is absent. */
|
|
95
|
+
export function kimiStatusToml(tabBin = "tab") {
|
|
96
|
+
return `\n[status_line]\ncommand = "${tabBin} statusline kimi"\n`;
|
|
97
|
+
}
|
|
98
|
+
/** Add the status line to a harness home that does not have one. Returns what was done. */
|
|
99
|
+
export async function installStatusLine(vendor, home, tabBin = "tab") {
|
|
100
|
+
const file = vendor === "xai" ? path.join(home, "config.toml") : path.join(home, "tui.toml");
|
|
101
|
+
const marker = vendor === "xai" ? "[ui.status_line]" : "[status_line]";
|
|
102
|
+
let current = "";
|
|
103
|
+
try {
|
|
104
|
+
current = await readFile(file, "utf8");
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
// No file yet.
|
|
108
|
+
}
|
|
109
|
+
if (current.includes(marker))
|
|
110
|
+
return "already";
|
|
111
|
+
await mkdir(home, { recursive: true, mode: 0o700 });
|
|
112
|
+
await writeFile(file, `${current.replace(/\s*$/, "\n")}${vendor === "xai" ? grokStatusToml(tabBin) : kimiStatusToml(tabBin)}`);
|
|
113
|
+
return "written";
|
|
114
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The harness vocabulary, the same as `@flocktab/shared`'s. `tab` ships to
|
|
3
|
+
* npm alone, so it carries a copy rather than the dependency.
|
|
4
|
+
*/
|
|
5
|
+
export const HARNESSES = ["claude", "codex", "grok", "kimi", "any"];
|
|
6
|
+
/** `dash-f4f` + `claude` -> `dash-f4f-claude`; `any` keeps the folder's name. */
|
|
7
|
+
export function agentNameFor(project, harness) {
|
|
8
|
+
return harness === "any" ? project : `${project}-${harness}`;
|
|
9
|
+
}
|