goosetools-worker 0.2.6 → 0.2.7
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/package.json +1 -1
- package/worker/agent-cli.js +11 -4
- package/worker/index.js +4 -0
- package/worker/install-helpers.js +113 -0
- package/worker/lock.js +18 -10
- package/worker/service-core.js +15 -17
package/package.json
CHANGED
package/worker/agent-cli.js
CHANGED
|
@@ -146,7 +146,11 @@ export function detect(agent) {
|
|
|
146
146
|
}
|
|
147
147
|
|
|
148
148
|
// Tools Claude must NOT touch in oneshot mode — everything is inlined.
|
|
149
|
-
const CLAUDE_TOOLS_OFF = "Read,Edit,Write,Bash,Glob,Grep,WebFetch,WebSearch";
|
|
149
|
+
const CLAUDE_TOOLS_OFF = "Read,Edit,Write,Bash,Glob,Grep,WebFetch,WebSearch,NotebookEdit,Task";
|
|
150
|
+
// "read" may open the files a prompt names (photos, references) and nothing
|
|
151
|
+
// else: no writing, no shell, no web. Denied explicitly, because an allow-list
|
|
152
|
+
// alone doesn't override a user whose own Claude settings pre-approve more.
|
|
153
|
+
const CLAUDE_READ_ONLY_OFF = "Edit,Write,Bash,WebFetch,WebSearch,NotebookEdit,Task";
|
|
150
154
|
|
|
151
155
|
/**
|
|
152
156
|
* Run a prompt through the chosen agent CLI.
|
|
@@ -181,6 +185,9 @@ export async function runAgent({
|
|
|
181
185
|
"acceptEdits",
|
|
182
186
|
"--allowedTools",
|
|
183
187
|
tools,
|
|
188
|
+
// The user's own MCP servers (a Gmail "send", say) must never be in
|
|
189
|
+
// reach of a prompt built from untrusted email.
|
|
190
|
+
"--strict-mcp-config",
|
|
184
191
|
];
|
|
185
192
|
if (resume) args.push("--resume", resume);
|
|
186
193
|
const raw = await run(normalized, args, { timeoutMs, cwd });
|
|
@@ -199,9 +206,9 @@ export async function runAgent({
|
|
|
199
206
|
return { text: raw, sessionId: namespaced(normalized, resume) };
|
|
200
207
|
}
|
|
201
208
|
}
|
|
202
|
-
const args = ["-p", prompt, "--output-format", "text"];
|
|
203
|
-
if (mode === "oneshot") args.push("--disallowedTools", CLAUDE_TOOLS_OFF);
|
|
204
|
-
if (mode === "read") args.push("--allowedTools", "Read");
|
|
209
|
+
const args = ["-p", prompt, "--output-format", "text", "--strict-mcp-config"];
|
|
210
|
+
if (mode === "oneshot" || mode === "text") args.push("--disallowedTools", CLAUDE_TOOLS_OFF);
|
|
211
|
+
if (mode === "read") args.push("--allowedTools", "Read", "--disallowedTools", CLAUDE_READ_ONLY_OFF);
|
|
205
212
|
const text = await run(normalized, args, { timeoutMs, cwd });
|
|
206
213
|
return { text, sessionId: null };
|
|
207
214
|
}
|
package/worker/index.js
CHANGED
|
@@ -566,6 +566,10 @@ async function runJob(job) {
|
|
|
566
566
|
|
|
567
567
|
// Render.
|
|
568
568
|
const outDir = join(dir, "output");
|
|
569
|
+
// A requeued job reuses this folder. Slides left from the earlier attempt
|
|
570
|
+
// (an 8-slide draft re-drafted as 6, or an old slide-03.mp4 that outranks the
|
|
571
|
+
// new slide-03.png) would be uploaded with the new deck.
|
|
572
|
+
rmSync(outDir, { recursive: true, force: true });
|
|
569
573
|
await execFileAsync(
|
|
570
574
|
"node",
|
|
571
575
|
[join(ROOT, "src", "render.js"), dir, "--aspect", job.aspect, "--out", outDir],
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// SHARED FILE: the source of truth is workers/shared/install-helpers.js in the
|
|
2
|
+
// goosetools repo; copied into each worker (as install-helpers.mjs for the
|
|
3
|
+
// overlay worker, a CommonJS package). Edit the shared one, then
|
|
4
|
+
// `npm run workers:sync`.
|
|
5
|
+
//
|
|
6
|
+
// The parts of installing a background worker that every worker gets wrong in
|
|
7
|
+
// the same way if each writes its own.
|
|
8
|
+
|
|
9
|
+
import { execSync } from "node:child_process";
|
|
10
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { dirname } from "node:path";
|
|
12
|
+
|
|
13
|
+
const sleepSync = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The node binary the background service should run.
|
|
17
|
+
*
|
|
18
|
+
* `process.execPath` under Homebrew is a versioned folder
|
|
19
|
+
* (/opt/homebrew/Cellar/node/25.8.2/bin/node) that `brew upgrade` + cleanup
|
|
20
|
+
* deletes, after which every worker fails to start, silently, forever. The
|
|
21
|
+
* stable link (/opt/homebrew/bin/node) follows upgrades, so prefer it whenever
|
|
22
|
+
* it points at the same install.
|
|
23
|
+
*/
|
|
24
|
+
export function stableNodePath(execPath = process.execPath) {
|
|
25
|
+
if (!/\/Cellar\/node(@\d+)?\//.test(execPath)) return execPath;
|
|
26
|
+
for (const candidate of ["/opt/homebrew/bin/node", "/usr/local/bin/node"]) {
|
|
27
|
+
try {
|
|
28
|
+
if (existsSync(candidate) && realpathSync(candidate).includes("/Cellar/node")) return candidate;
|
|
29
|
+
} catch {}
|
|
30
|
+
}
|
|
31
|
+
return execPath;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The newest published version, asked of the registry itself, not a cache. */
|
|
35
|
+
export function latestPublished(pkg) {
|
|
36
|
+
try {
|
|
37
|
+
return execSync(`npm view ${pkg} version --prefer-online`, {
|
|
38
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
39
|
+
timeout: 20000,
|
|
40
|
+
shell: true,
|
|
41
|
+
})
|
|
42
|
+
.toString()
|
|
43
|
+
.trim();
|
|
44
|
+
} catch {
|
|
45
|
+
return null; // offline, or npm unreachable
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Install exactly the newest version of `pkg` into `prefix`, and prove it.
|
|
51
|
+
*
|
|
52
|
+
* `npm install pkg@latest` reads a cached, abbreviated copy of the package
|
|
53
|
+
* listing that can lag a publish by minutes, so right after a release it
|
|
54
|
+
* quietly reinstalled the previous version and reported success. This asks
|
|
55
|
+
* the registry for the version first, installs that exact version with
|
|
56
|
+
* --prefer-online, and retries (for a few minutes) until what's on disk is
|
|
57
|
+
* what was asked for.
|
|
58
|
+
*/
|
|
59
|
+
export function installExact({ pkg, prefix, installedVersion, log = console.log }) {
|
|
60
|
+
const target = latestPublished(pkg);
|
|
61
|
+
if (!target) {
|
|
62
|
+
// Can't reach the registry to ask; fall back to whatever it will give.
|
|
63
|
+
execSync(`npm install --no-fund --no-audit --loglevel=error --prefix "${prefix}" ${pkg}@latest`, {
|
|
64
|
+
stdio: ["ignore", "inherit", "inherit"],
|
|
65
|
+
shell: true,
|
|
66
|
+
});
|
|
67
|
+
return installedVersion();
|
|
68
|
+
}
|
|
69
|
+
const attempts = 8;
|
|
70
|
+
for (let i = 1; i <= attempts; i++) {
|
|
71
|
+
try {
|
|
72
|
+
execSync(
|
|
73
|
+
`npm install --no-fund --no-audit --loglevel=error --prefer-online --prefix "${prefix}" ${pkg}@${target}`,
|
|
74
|
+
{ stdio: ["ignore", "inherit", "pipe"], shell: true },
|
|
75
|
+
);
|
|
76
|
+
if (installedVersion() === target) return target;
|
|
77
|
+
} catch (e) {
|
|
78
|
+
if (i === attempts) throw e;
|
|
79
|
+
}
|
|
80
|
+
if (i < attempts) {
|
|
81
|
+
log(` npm hasn't caught up with ${pkg}@${target} yet — trying again in 20s (${i}/${attempts})`);
|
|
82
|
+
sleepSync(20000);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
throw new Error(`installed ${installedVersion() ?? "nothing"} but ${target} is the latest — try again in a few minutes`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Write the shared ~/.goosetools/env, keeping any keys this installer doesn't
|
|
90
|
+
* own (VOICE_DIR and friends) and never leaving it world-readable, even for a
|
|
91
|
+
* moment: written 0600 to a temp file and renamed into place.
|
|
92
|
+
*/
|
|
93
|
+
export function writeEnv(file, updates) {
|
|
94
|
+
const existing = {};
|
|
95
|
+
if (existsSync(file)) {
|
|
96
|
+
for (const line of readFileSync(file, "utf8").split("\n")) {
|
|
97
|
+
const i = line.indexOf("=");
|
|
98
|
+
if (i > 0 && !line.trim().startsWith("#")) existing[line.slice(0, i).trim()] = line.slice(i + 1).trim();
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const merged = { ...existing, ...updates };
|
|
102
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
103
|
+
const tmp = `${file}.tmp-${process.pid}`;
|
|
104
|
+
writeFileSync(
|
|
105
|
+
tmp,
|
|
106
|
+
Object.entries(merged)
|
|
107
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
108
|
+
.join("\n") + "\n",
|
|
109
|
+
{ mode: 0o600 },
|
|
110
|
+
);
|
|
111
|
+
chmodSync(tmp, 0o600);
|
|
112
|
+
renameSync(tmp, file);
|
|
113
|
+
}
|
package/worker/lock.js
CHANGED
|
@@ -35,7 +35,7 @@ export function acquireWorkerLock(name, { label, stopHint }) {
|
|
|
35
35
|
const file = join(LOCK_DIR, `${name}.pid`);
|
|
36
36
|
|
|
37
37
|
const holder = readHolder(file);
|
|
38
|
-
if (holder && isAlive(holder.pid)) {
|
|
38
|
+
if (holder && isAlive(holder.pid, holder.script)) {
|
|
39
39
|
console.log(
|
|
40
40
|
`\n${label} is already running on this computer (pid ${holder.pid}${
|
|
41
41
|
holder.since ? `, since ${holder.since}` : ""
|
|
@@ -58,7 +58,9 @@ export function acquireWorkerLock(name, { label, stopHint }) {
|
|
|
58
58
|
console.log(`\n${label} started somewhere else a moment ago. Stopping here.\n`);
|
|
59
59
|
process.exit(0);
|
|
60
60
|
}
|
|
61
|
-
|
|
61
|
+
// The script path lets a later start tell THIS worker apart from another
|
|
62
|
+
// process that has since been given the same pid.
|
|
63
|
+
writeSync(fd, `${process.pid}\n${new Date().toISOString()}\n${process.argv[1] ?? ""}\n`);
|
|
62
64
|
closeSync(fd);
|
|
63
65
|
|
|
64
66
|
const release = () => rmSync(file, { force: true });
|
|
@@ -74,29 +76,35 @@ export function acquireWorkerLock(name, { label, stopHint }) {
|
|
|
74
76
|
function readHolder(file) {
|
|
75
77
|
if (!existsSync(file)) return null;
|
|
76
78
|
try {
|
|
77
|
-
const [pid, since] = readFileSync(file, "utf8").split("\n");
|
|
79
|
+
const [pid, since, script] = readFileSync(file, "utf8").split("\n");
|
|
78
80
|
const n = Number.parseInt(pid, 10);
|
|
79
|
-
return Number.isFinite(n)
|
|
81
|
+
return Number.isFinite(n)
|
|
82
|
+
? { pid: n, since: since?.trim() || null, script: script?.trim() || null }
|
|
83
|
+
: null;
|
|
80
84
|
} catch {
|
|
81
85
|
return null;
|
|
82
86
|
}
|
|
83
87
|
}
|
|
84
88
|
|
|
85
89
|
// A pid file outlives a SIGKILLed worker, and pids get reused — so "is that
|
|
86
|
-
// pid alive" isn't enough on its own.
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
|
|
90
|
+
// pid alive" isn't enough on its own. And "is it a node process" isn't either:
|
|
91
|
+
// all four workers are node processes started together at login, so after a
|
|
92
|
+
// crash and reboot a sibling worker can inherit the dead one's pid and lock it
|
|
93
|
+
// out indefinitely. So the lock records the worker's script, and only a
|
|
94
|
+
// process running that script counts. (Older lock files without it fall back
|
|
95
|
+
// to the node check.)
|
|
96
|
+
function isAlive(pid, script) {
|
|
90
97
|
try {
|
|
91
98
|
process.kill(pid, 0);
|
|
92
99
|
} catch {
|
|
93
100
|
return false;
|
|
94
101
|
}
|
|
95
102
|
try {
|
|
96
|
-
|
|
103
|
+
const command = execFileSync("ps", ["-p", String(pid), "-o", "command="], {
|
|
97
104
|
encoding: "utf8",
|
|
98
105
|
stdio: ["ignore", "pipe", "ignore"],
|
|
99
|
-
})
|
|
106
|
+
});
|
|
107
|
+
return script ? command.includes(script) : /node/.test(command);
|
|
100
108
|
} catch {
|
|
101
109
|
return false;
|
|
102
110
|
}
|
package/worker/service-core.js
CHANGED
|
@@ -22,7 +22,6 @@
|
|
|
22
22
|
|
|
23
23
|
import { execSync } from "node:child_process";
|
|
24
24
|
import {
|
|
25
|
-
chmodSync,
|
|
26
25
|
existsSync,
|
|
27
26
|
mkdirSync,
|
|
28
27
|
readFileSync,
|
|
@@ -32,6 +31,7 @@ import {
|
|
|
32
31
|
} from "node:fs";
|
|
33
32
|
import { homedir } from "node:os";
|
|
34
33
|
import { dirname, join } from "node:path";
|
|
34
|
+
import { installExact, latestPublished, stableNodePath, writeEnv } from "./install-helpers.js";
|
|
35
35
|
|
|
36
36
|
export const GOOSE_DIR = join(homedir(), ".goosetools");
|
|
37
37
|
const ENV_FILE = join(GOOSE_DIR, "env");
|
|
@@ -65,7 +65,7 @@ function npmGlobalBin() {
|
|
|
65
65
|
// `claude` lives), plus the usual suspects.
|
|
66
66
|
function servicePath() {
|
|
67
67
|
const parts = [
|
|
68
|
-
dirname(
|
|
68
|
+
dirname(stableNodePath()),
|
|
69
69
|
npmGlobalBin(),
|
|
70
70
|
...(process.platform === "win32"
|
|
71
71
|
? [process.env.PATH ?? ""]
|
|
@@ -93,10 +93,9 @@ function readEnvFile() {
|
|
|
93
93
|
);
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
// Keeps keys other tools put there, and is never world-readable (install-helpers).
|
|
96
97
|
function writeEnvFile({ url, token }) {
|
|
97
|
-
|
|
98
|
-
writeFileSync(ENV_FILE, `GOOSETOOLS_URL=${url}\nWORKER_TOKEN=${token}\n`);
|
|
99
|
-
chmodSync(ENV_FILE, 0o600);
|
|
98
|
+
writeEnv(ENV_FILE, { GOOSETOOLS_URL: url, WORKER_TOKEN: token });
|
|
100
99
|
}
|
|
101
100
|
|
|
102
101
|
function compareVersions(a, b) {
|
|
@@ -163,12 +162,15 @@ export function createService(cfg) {
|
|
|
163
162
|
console.log("Setting up the background copy (this can take a minute)…");
|
|
164
163
|
mkdirSync(APP_PREFIX, { recursive: true });
|
|
165
164
|
// From a git checkout (development), install the checkout itself; normal
|
|
166
|
-
// npx users get the published
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
165
|
+
// npx users get exactly the newest published version (install-helpers).
|
|
166
|
+
if (isCheckout(root)) {
|
|
167
|
+
execSync(`npm install --no-fund --no-audit --loglevel=error --prefix "${APP_PREFIX}" "${root}"`, {
|
|
168
|
+
stdio: ["ignore", "inherit", "inherit"],
|
|
169
|
+
shell: true,
|
|
170
|
+
});
|
|
171
|
+
} else {
|
|
172
|
+
installExact({ pkg: cfg.pkg, prefix: APP_PREFIX, installedVersion });
|
|
173
|
+
}
|
|
172
174
|
if (!existsSync(INSTALLED_CLI)) {
|
|
173
175
|
throw new Error("background copy did not install where expected");
|
|
174
176
|
}
|
|
@@ -186,7 +188,7 @@ export function createService(cfg) {
|
|
|
186
188
|
<string>${cfg.macLabel}</string>
|
|
187
189
|
<key>ProgramArguments</key>
|
|
188
190
|
<array>
|
|
189
|
-
<string>${esc(
|
|
191
|
+
<string>${esc(stableNodePath())}</string>
|
|
190
192
|
<string>${esc(INSTALLED_CLI)}</string>
|
|
191
193
|
<string>run</string>
|
|
192
194
|
</array>
|
|
@@ -272,11 +274,7 @@ export function createService(cfg) {
|
|
|
272
274
|
}
|
|
273
275
|
|
|
274
276
|
function latestVersion() {
|
|
275
|
-
|
|
276
|
-
return sh(`npm view ${cfg.pkg} version`, { shell: true, timeout: 15000 });
|
|
277
|
-
} catch {
|
|
278
|
-
return null; // offline, or npm unreachable — not an error worth failing on
|
|
279
|
-
}
|
|
277
|
+
return latestPublished(cfg.pkg); // null when offline — not worth failing on
|
|
280
278
|
}
|
|
281
279
|
|
|
282
280
|
// launchctl prints "<pid>\t<last exit>\t<label>"; a "-" pid means registered
|