@voidlabs.gg/voidshift-agents 0.4.1 → 0.4.3

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/README.md CHANGED
@@ -5,7 +5,7 @@ One CLI for the Void dev tools. Install it once, run `vsf` (short for
5
5
  defaults you can change. Everything setup does can be seen and changed later
6
6
  from the command line.
7
7
 
8
- > Early release. macOS only for now; Windows and Linux are next.
8
+ > Early release. macOS and Linux (awake is macOS only); Windows is next.
9
9
 
10
10
  ```sh
11
11
  npm i -g @voidlabs.gg/voidshift
@@ -104,10 +104,13 @@ to trust the recall hook once: run `/hooks` in Codex.
104
104
  - **Your own logins.** Agents run the official `claude` and `codex` CLIs you
105
105
  already use, signed in with your own account. VoidShift never reads or
106
106
  stores those credentials. The Linear API key for the runner lives in your
107
- macOS Keychain.
107
+ macOS Keychain, or on Linux in `~/.config/voidshift/agents/secrets.json`
108
+ (mode 0600).
108
109
  - **Trust.** The Dev agent can run shell commands inside its copy of the repo
109
110
  so it can install and test. Review its pull requests like any other.
110
111
  - **Your config** lives in `~/.config/voidshift`.
112
+ - **Linux.** The runner is a systemd user timer. Run
113
+ `sudo loginctl enable-linger $USER` so it keeps going after you log out.
111
114
 
112
115
  ```sh
113
116
  vsf doctor # check everything is set up, and whether an update is out
package/dist/control.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { type Current, type RunEntry } from './state.ts';
2
2
  import type { Check, ToolCommand, ToolContext } from './types.ts';
3
+ export { parsePlistArgs } from './launchd.ts';
3
4
  export declare function parseAge(input: string): number | null;
4
5
  export declare function formatDuration(ms: number): string;
5
6
  export declare function stampDate(stamp: string): Date;
@@ -12,7 +13,6 @@ export declare function scanRunnerErrors(text: string, now: number): {
12
13
  count: number;
13
14
  last: string;
14
15
  };
15
- export declare function parsePlistArgs(plist: string): string[];
16
16
  export declare const controlUsage: Record<string, string>;
17
17
  export interface CancelDeps {
18
18
  ours: (pid: number) => boolean;
package/dist/control.js CHANGED
@@ -3,9 +3,9 @@ import { closeSync, existsSync, openSync, readFileSync, readSync, readdirSync, r
3
3
  import { join } from 'node:path';
4
4
  import { parseArgs } from 'node:util';
5
5
  import { agentsDir, readAgentsConfig } from "./config.js";
6
- import { readLinearKey } from "./keychain.js";
6
+ import { readLinearKey } from "./secrets.js";
7
7
  import { isPaused, listRuns, ours, clearCurrent, liveCurrent, readCurrent, setPaused, writeRun } from "./state.js";
8
- import { plistPath, runnerLoaded, startRunner, stopRunner } from "./launchd.js";
8
+ import { runnerService } from "./service.js";
9
9
  import { createLinear } from "./linear.js";
10
10
  import { parseStep, resolveName, stepNames } from "./names.js";
11
11
  import { isStatusLabel } from "./workflow.js";
@@ -14,8 +14,9 @@ const HOUR = 3_600_000;
14
14
  const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
15
15
  const CARD_ID = /^[A-Za-z][A-Za-z0-9]*-\d+$/;
16
16
  const ISO_LINE = /^(\d{4}-\d\d-\d\dT[\d:.]+Z)\s*(.*)$/;
17
- function linear() {
18
- const key = readLinearKey();
17
+ export { parsePlistArgs } from "./launchd.js";
18
+ function linear(configDir) {
19
+ const key = readLinearKey(configDir);
19
20
  if (!key)
20
21
  throw new Error('No Linear API key. Run vsf agents key');
21
22
  return createLinear(key);
@@ -88,12 +89,6 @@ export function scanRunnerErrors(text, now) {
88
89
  }
89
90
  return { count: bad.length, last: bad.at(-1) ?? '' };
90
91
  }
91
- // The <string> values of a launchd plist's ProgramArguments.
92
- export function parsePlistArgs(plist) {
93
- const block = /<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/.exec(plist)?.[1] ?? '';
94
- const unxml = (s) => s.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&');
95
- return [...block.matchAll(/<string>([\s\S]*?)<\/string>/g)].map((m) => unxml(m[1]));
96
- }
97
92
  function lastTickOf(text) {
98
93
  for (const line of text.split('\n').reverse()) {
99
94
  const m = ISO_LINE.exec(line);
@@ -160,7 +155,7 @@ const guard = (fn) => async (args, ctx) => {
160
155
  };
161
156
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
162
157
  function runnerState(ctx) {
163
- if (!runnerLoaded())
158
+ if (!runnerService().loaded())
164
159
  return 'stopped';
165
160
  return isPaused(ctx.configDir) ? 'paused' : 'running';
166
161
  }
@@ -219,7 +214,7 @@ async function startCmd(ctx) {
219
214
  return 1;
220
215
  }
221
216
  try {
222
- await linear().viewer();
217
+ await linear(ctx.configDir).viewer();
223
218
  }
224
219
  catch (err) {
225
220
  const msg = err.message;
@@ -227,18 +222,18 @@ async function startCmd(ctx) {
227
222
  return 1;
228
223
  }
229
224
  setPaused(ctx.configDir, false);
230
- if (!startRunner(ctx.home, program(ctx), logPath(ctx.configDir))) {
225
+ if (!runnerService().start(ctx.home, program(ctx), logPath(ctx.configDir))) {
231
226
  ctx.log('Could not start the runner. Check vsf agents logs.');
232
227
  return 1;
233
228
  }
234
229
  return 0;
235
230
  }
236
231
  async function stopCmd(ctx) {
237
- stopRunner(ctx.home);
238
- // launchd may have stopped the pass; finish or tidy up whatever the lock still names.
232
+ runnerService().stop(ctx.home);
233
+ // Stopping the runner may have stopped the pass; finish or tidy up whatever the lock still names.
239
234
  const c = readCurrent(ctx.configDir);
240
235
  if (c)
241
- await cancelCurrent(ctx, c, realDeps(ctx, linear));
236
+ await cancelCurrent(ctx, c, realDeps(ctx, () => linear(ctx.configDir)));
242
237
  ctx.log('Runner stopped.');
243
238
  return 0;
244
239
  }
@@ -358,7 +353,7 @@ const run = guard(async (args, ctx) => {
358
353
  return 2;
359
354
  }
360
355
  }
361
- const code = await tick({ configDir: ctx.configDir, config, linear: linear(), log: ctx.log, selfCommand: ctx.selfCommand }, card, { workflow, from });
356
+ const code = await tick({ configDir: ctx.configDir, config, linear: linear(ctx.configDir), log: ctx.log, selfCommand: ctx.selfCommand }, card, { workflow, from });
362
357
  if (!card && code === 0) {
363
358
  if (isPaused(ctx.configDir))
364
359
  ctx.log('The runner is paused, so nothing was picked up. vsf agents resume, or name a card.');
@@ -383,7 +378,7 @@ const cancel = guard(async (args, ctx) => {
383
378
  ctx.log(`${c.card ?? 'Another card'} is running, not ${card}.`);
384
379
  return 1;
385
380
  }
386
- await cancelCurrent(ctx, c, realDeps(ctx, linear));
381
+ await cancelCurrent(ctx, c, realDeps(ctx, () => linear(ctx.configDir)));
387
382
  return 0;
388
383
  });
389
384
  const retry = guard(async (args, ctx) => {
@@ -399,7 +394,7 @@ const retry = guard(async (args, ctx) => {
399
394
  }
400
395
  const config = readAgentsConfig(ctx.configDir);
401
396
  const names = Object.keys(config.workflows);
402
- const l = linear();
397
+ const l = linear(ctx.configDir);
403
398
  const card = await l.card(id);
404
399
  const startsOn = (n) => config.workflows[n].triggers.some((t) => t.team === card.teamKey);
405
400
  let chosen;
@@ -479,7 +474,7 @@ const show = guard(async (args, ctx) => {
479
474
  }
480
475
  let labels = null;
481
476
  try {
482
- labels = (await linear().card(id)).labels;
477
+ labels = (await linear(ctx.configDir).card(id)).labels;
483
478
  }
484
479
  catch {
485
480
  // live labels are optional
@@ -646,16 +641,16 @@ const clean = guard(async (args, ctx) => {
646
641
  ctx.log(`Removed ${plural(worktrees.length, 'worktree')} and ${plural(old.length, 'run')}.`);
647
642
  return 0;
648
643
  });
649
- // Internal: launchd runs this every minute.
644
+ // Internal: the runner (launchd or systemd) runs this every minute.
650
645
  const tickCmd = async (_args, ctx) => {
651
646
  const stamped = (l) => ctx.log(`${new Date().toISOString()} ${l}`);
652
647
  try {
653
648
  const config = readAgentsConfig(ctx.configDir);
654
- if (!readLinearKey()) {
649
+ if (!readLinearKey(ctx.configDir)) {
655
650
  stamped('No Linear API key. Run vsf agents key.');
656
651
  return 1;
657
652
  }
658
- return await tick({ configDir: ctx.configDir, config, linear: linear(), log: stamped, selfCommand: ctx.selfCommand });
653
+ return await tick({ configDir: ctx.configDir, config, linear: linear(ctx.configDir), log: stamped, selfCommand: ctx.selfCommand });
659
654
  }
660
655
  catch (err) {
661
656
  stamped(`Could not run the check: ${err.message}`);
@@ -690,9 +685,10 @@ export async function controlChecks(ctx) {
690
685
  : state === 'paused'
691
686
  ? { name: 'agent runner', status: 'warn', detail: 'paused', fix: 'vsf agents resume' }
692
687
  : { name: 'agent runner', status: 'warn', detail: 'not running', fix: 'vsf agents start' });
693
- const plist = plistPath(ctx.home);
694
- if (existsSync(plist)) {
695
- const missing = parsePlistArgs(readFileSync(plist, 'utf8')).filter((a) => a.startsWith('/') && !existsSync(a));
688
+ const service = runnerService();
689
+ const installed = service.programArgs(ctx.home);
690
+ if (installed) {
691
+ const missing = installed.filter((a) => a.startsWith('/') && !existsSync(a));
696
692
  checks.push(missing.length
697
693
  ? { name: 'runner program', status: 'fail', detail: `runner points at a missing ${missing[0]}`, fix: 'vsf agents restart' }
698
694
  : { name: 'runner program', status: 'ok', detail: 'found' });
@@ -706,6 +702,7 @@ export async function controlChecks(ctx) {
706
702
  ? { name: 'runner errors', status: 'warn', detail: `${plural(count, 'error')} in the last hour, last: ${last.slice(0, 120)}`, fix: 'vsf agents logs' }
707
703
  : { name: 'runner errors', status: 'ok', detail: 'no errors in the last hour' });
708
704
  }
705
+ checks.push(...service.checks());
709
706
  checks.push(engineLogin(readAgentsConfig(ctx.configDir).engine));
710
707
  return checks;
711
708
  }
package/dist/git.d.ts CHANGED
@@ -24,7 +24,7 @@ export declare function summarizeChecks(text: string): CheckSummary;
24
24
  export declare function ciReport(s: CheckSummary): string;
25
25
  export declare function waitForPrHead(worktree: string, branch: string, sha: string, timeoutMs?: number): Promise<boolean>;
26
26
  export declare function waitForChecks(worktree: string, branch: string, timeoutMs?: number, settleMs?: number): Promise<{
27
- verdict: 'SHIP' | 'FIX' | 'SKIP';
27
+ verdict: 'SHIP' | 'FIX' | 'SKIP' | 'PENDING';
28
28
  report: string;
29
29
  }>;
30
30
  export declare function resetWorktree(worktree: string): void;
package/dist/git.js CHANGED
@@ -124,7 +124,7 @@ export async function waitForChecks(worktree, branch, timeoutMs = 30 * 60_000, s
124
124
  if (!summary.failed.length && !summary.pending.length)
125
125
  return resolve({ verdict: 'SHIP', report: `CI passed (${summary.passed} checks).` });
126
126
  if (!summary.failed.length)
127
- return resolve({ verdict: 'FIX', report: `CI did not finish in time. Still running: ${summary.pending.join(', ')}.` });
127
+ return resolve({ verdict: 'PENDING', report: `CI did not finish in time. Still running: ${summary.pending.join(', ')}.` });
128
128
  resolve({ verdict: 'FIX', report: ciReport(summary) });
129
129
  });
130
130
  });
package/dist/index.js CHANGED
@@ -6,8 +6,8 @@ import { parseArgs } from 'node:util';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { AGENTS, STEPS, TEMPLATES, isProducer, agentsDir, describeSteps, describeTriggers, describeWorkflow, stepEnabled, visibleAgents, readAgentsConfig, writeAgentsConfig, DEFAULTS } from "./config.js";
8
8
  import { controlChecks, controlCommands, controlUsage } from "./control.js";
9
- import { looksLikeLinearKey, readLinearKey, storeLinearKey } from "./keychain.js";
10
- import { startRunner, stopRunner } from "./launchd.js";
9
+ import { keyStore, looksLikeLinearKey, readLinearKey, storeLinearKey } from "./secrets.js";
10
+ import { runnerService } from "./service.js";
11
11
  import { createLinear } from "./linear.js";
12
12
  import { duplicateTriggerWarnings, moveKey, parseTrigger, renameKey, sameTrigger, validateConfig, validateWorkflow, workflowsUsing } from "./manage.js";
13
13
  import { parseStep, resolveName, stepNames } from "./names.js";
@@ -81,14 +81,14 @@ async function chooseHomes(ui, configDir, saved) {
81
81
  return homes;
82
82
  }
83
83
  // Checks the key against Linear, and only then stores it.
84
- async function verifyAndStore(key, info, warn) {
84
+ async function verifyAndStore(configDir, key, info, warn) {
85
85
  try {
86
86
  const me = await createLinear(key).viewer();
87
- if (!storeLinearKey(key)) {
88
- warn('Linear accepted the key but the Keychain did not store it.');
87
+ if (!storeLinearKey(configDir, key)) {
88
+ warn(`Linear accepted the key but it could not be stored in ${keyStore()}.`);
89
89
  return null;
90
90
  }
91
- info(`Linear connected as ${me.name}. Key stored in your macOS Keychain.`);
91
+ info(`Linear connected as ${me.name}. Key stored in ${keyStore()}.`);
92
92
  return me.name;
93
93
  }
94
94
  catch (err) {
@@ -97,14 +97,14 @@ async function verifyAndStore(key, info, warn) {
97
97
  }
98
98
  }
99
99
  // Asks for the key, checks it against Linear, and only then stores it.
100
- async function setupKey(ui) {
100
+ async function setupKey(ui, configDir) {
101
101
  ui.info('Agents need a Linear personal API key (Linear > Settings > Security & access > API keys).');
102
102
  const key = await ui.secret('Paste your Linear API key', (v) => (looksLikeLinearKey(v.trim()) ? undefined : 'Linear keys start with lin_api_'));
103
- return verifyAndStore(key.trim(), ui.info, ui.warn);
103
+ return verifyAndStore(configDir, key.trim(), ui.info, ui.warn);
104
104
  }
105
- async function currentLinearUser() {
105
+ async function currentLinearUser(configDir) {
106
106
  try {
107
- return readLinearKey() ? (await linear().viewer()).name : null;
107
+ return readLinearKey(configDir) ? (await linear(configDir).viewer()).name : null;
108
108
  }
109
109
  catch {
110
110
  return null;
@@ -181,8 +181,8 @@ function syncLinks(chosen, all, enabled, log) {
181
181
  log(`Agents linked in ${homeLabel(home)}: ${linked} linked${skipped ? `, ${skipped} skipped (something else is already there)` : ''}`);
182
182
  }
183
183
  }
184
- function linear() {
185
- const key = readLinearKey();
184
+ function linear(configDir) {
185
+ const key = readLinearKey(configDir);
186
186
  if (!key)
187
187
  throw new Error('No Linear API key. Run vsf agents key');
188
188
  return createLinear(key);
@@ -489,7 +489,7 @@ function pickWorkflow(config, input, ctx) {
489
489
  }
490
490
  async function teamsOrNull(ctx) {
491
491
  try {
492
- return await linear().teams();
492
+ return await linear(ctx.configDir).teams();
493
493
  }
494
494
  catch (err) {
495
495
  ctx.log(`Could not list Linear teams: ${err.message}`);
@@ -1045,22 +1045,22 @@ const agents = {
1045
1045
  config.models = { ...DEFAULTS.models };
1046
1046
  }
1047
1047
  config.engine = engine;
1048
- const name = await currentLinearUser();
1048
+ const name = await currentLinearUser(ctx.configDir);
1049
1049
  if (!name || !(await ui.confirm(`Keep using Linear as ${name}?`))) {
1050
- if (!(await setupKey(ui)))
1050
+ if (!(await setupKey(ui, ctx.configDir)))
1051
1051
  throw new Error('Linear is not connected. Run vsf setup agents to retry.');
1052
1052
  }
1053
1053
  await setupProjects(ui, config, ctx.log);
1054
- await setupWorkflows(ui, config, await linear().teams(), ctx.log);
1054
+ await setupWorkflows(ui, config, await linear(ctx.configDir).teams(), ctx.log);
1055
1055
  writeAgentsConfig(ctx.configDir, config);
1056
1056
  if (Object.keys(config.workflows).length && (await ui.confirm('Start the background runner now? It checks Linear every minute for cards that start your workflows.'))) {
1057
- const ok = startRunner(ctx.home, [...ctx.selfCommand, 'agents', 'tick'], join(agentsDir(ctx.configDir), 'runner.log'));
1057
+ const ok = runnerService().start(ctx.home, [...ctx.selfCommand, 'agents', 'tick'], join(agentsDir(ctx.configDir), 'runner.log'));
1058
1058
  ctx.log(ok ? 'Runner started.' : 'Could not start the runner. Try vsf agents start.');
1059
1059
  }
1060
1060
  ctx.log('In Claude Code or Codex, connect Linear too, then use /vs-plan, /vs-plan-review and /vs-publish on a card.');
1061
1061
  },
1062
1062
  async uninstall(ctx) {
1063
- stopRunner(ctx.home);
1063
+ runnerService().stop(ctx.home);
1064
1064
  syncLinks([], candidateHomes(ctx.configDir, readAgentsConfig(ctx.configDir).homes), [], ctx.log);
1065
1065
  ctx.log('Agents unlinked and the runner stopped. Config and the Linear key were kept.');
1066
1066
  },
@@ -1078,7 +1078,7 @@ const agents = {
1078
1078
  : { name: `agents in ${homeLabel(h)}`, status: 'warn', detail: `missing ${missing.join(', ')}`, fix: `vsf agents link ${tilde(h.dir)}` });
1079
1079
  }
1080
1080
  try {
1081
- const me = await linear().viewer();
1081
+ const me = await linear(ctx.configDir).viewer();
1082
1082
  checks.push({ name: 'Linear', status: 'ok', detail: `connected as ${me.name}` });
1083
1083
  }
1084
1084
  catch (err) {
@@ -1142,13 +1142,13 @@ const agents = {
1142
1142
  return 2;
1143
1143
  }
1144
1144
  if (interactive())
1145
- return (await setupKey(ctx.ui)) ? 0 : 1;
1145
+ return (await setupKey(ctx.ui, ctx.configDir)) ? 0 : 1;
1146
1146
  const key = readFileSync(0, 'utf8').trim();
1147
1147
  if (!looksLikeLinearKey(key)) {
1148
1148
  ctx.log('Linear keys start with lin_api_');
1149
1149
  return 2;
1150
1150
  }
1151
- return (await verifyAndStore(key, ctx.log, ctx.log)) ? 0 : 1;
1151
+ return (await verifyAndStore(ctx.configDir, key, ctx.log, ctx.log)) ? 0 : 1;
1152
1152
  },
1153
1153
  async help(args, ctx) {
1154
1154
  const want = args.join(' ').trim();
package/dist/launchd.d.ts CHANGED
@@ -5,3 +5,4 @@ export declare function runnerEnv(): Record<string, string>;
5
5
  export declare const runnerLoaded: () => boolean;
6
6
  export declare function startRunner(home: string, program: string[], logPath: string): boolean;
7
7
  export declare function stopRunner(home: string): void;
8
+ export declare function parsePlistArgs(plist: string): string[];
package/dist/launchd.js CHANGED
@@ -66,3 +66,9 @@ export function stopRunner(home) {
66
66
  launchctl(['bootout', `${domain()}/${LABEL}`]);
67
67
  rmSync(plistPath(home), { force: true });
68
68
  }
69
+ // The <string> values of a launchd plist's ProgramArguments.
70
+ export function parsePlistArgs(plist) {
71
+ const block = /<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/.exec(plist)?.[1] ?? '';
72
+ const unxml = (s) => s.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&');
73
+ return [...block.matchAll(/<string>([\s\S]*?)<\/string>/g)].map((m) => unxml(m[1]));
74
+ }
package/dist/runner.js CHANGED
@@ -155,7 +155,15 @@ export async function runCard(card, trigger, workflowName, workflow, project, rc
155
155
  if (!(await waitForPrHead(worktree, card.branchName, sha))) {
156
156
  throw new Park(`GitHub never showed commit ${sha.slice(0, 9)} on the pull request, so its CI could not be read. PR: ${prUrl}`);
157
157
  }
158
- const ci = await waitForChecks(worktree, card.branchName);
158
+ // A slow CI is not a failed one: wait a second window, then park
159
+ // rather than send Dev a round it can only answer with no commit.
160
+ let ci = await waitForChecks(worktree, card.branchName);
161
+ if (ci.verdict === 'PENDING') {
162
+ say('CI still running, waiting another window');
163
+ ci = await waitForChecks(worktree, card.branchName);
164
+ }
165
+ if (ci.verdict === 'PENDING')
166
+ throw new Park(`${ci.report} PR: ${prUrl}`);
159
167
  if (ci.verdict === 'FIX') {
160
168
  end(rec, 'FIX', ci.report);
161
169
  return { step: label, verdict: 'FIX', report: ci.report };
@@ -0,0 +1,9 @@
1
+ export declare const looksLikeLinearKey: (key: string) => boolean;
2
+ export declare const secretsPath: (configDir: string) => string;
3
+ export declare const keyStore: (platform?: NodeJS.Platform) => "your macOS Keychain" | "a file only you can read";
4
+ export declare function readSecretsFile(path: string): Record<string, string>;
5
+ export declare function writeSecretsFile(path: string, values: Record<string, string>): void;
6
+ export declare function readSecret(configDir: string, name: string, platform?: NodeJS.Platform): string | null;
7
+ export declare function storeSecret(configDir: string, name: string, value: string, platform?: NodeJS.Platform): boolean;
8
+ export declare const readLinearKey: (configDir: string) => string | null;
9
+ export declare const storeLinearKey: (configDir: string, key: string) => boolean;
@@ -0,0 +1,55 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
3
+ import { userInfo } from 'node:os';
4
+ import { dirname, join } from 'node:path';
5
+ import { agentsDir } from "./config.js";
6
+ // Keys live in the macOS Keychain, or on other platforms in a file only this
7
+ // user can read, in the agents config folder.
8
+ const KEY_SHAPE = /^lin_api_[A-Za-z0-9]+$/;
9
+ // Values the Keychain is handed on stdin; anything that could break out of
10
+ // the `security -i` command line is refused.
11
+ const SAFE_VALUE = /^[A-Za-z0-9_\-.:/+=]+$/;
12
+ export const looksLikeLinearKey = (key) => KEY_SHAPE.test(key);
13
+ export const secretsPath = (configDir) => join(agentsDir(configDir), 'secrets.json');
14
+ // Where keys are kept on this machine, for messages.
15
+ export const keyStore = (platform = process.platform) => platform === 'darwin' ? 'your macOS Keychain' : 'a file only you can read';
16
+ // The secrets file, refused when anyone but its owner can read it.
17
+ export function readSecretsFile(path) {
18
+ if (!existsSync(path))
19
+ return {};
20
+ const mode = statSync(path).mode & 0o777;
21
+ if (mode & 0o077)
22
+ throw new Error(`${path} can be read by other users (mode ${mode.toString(8)}). Run chmod 600 on it.`);
23
+ return JSON.parse(readFileSync(path, 'utf8'));
24
+ }
25
+ // Written to a temporary file with mode 0600, then renamed into place, so the
26
+ // file is never readable by others, not even for a moment.
27
+ export function writeSecretsFile(path, values) {
28
+ mkdirSync(dirname(path), { recursive: true });
29
+ const tmp = `${path}.${process.pid}.tmp`;
30
+ writeFileSync(tmp, `${JSON.stringify(values, null, 2)}\n`, { mode: 0o600 });
31
+ chmodSync(tmp, 0o600);
32
+ renameSync(tmp, path);
33
+ }
34
+ const service = (name) => `voidshift-${name}`;
35
+ export function readSecret(configDir, name, platform = process.platform) {
36
+ if (platform === 'darwin') {
37
+ const r = spawnSync('/usr/bin/security', ['find-generic-password', '-s', service(name), '-w'], { encoding: 'utf8' });
38
+ return (r.status === 0 ? r.stdout.trim() : '') || null;
39
+ }
40
+ return readSecretsFile(secretsPath(configDir))[name] || null;
41
+ }
42
+ export function storeSecret(configDir, name, value, platform = process.platform) {
43
+ if (!SAFE_VALUE.test(value) || !/^[a-z-]+$/.test(name))
44
+ return false;
45
+ if (platform === 'darwin') {
46
+ // On stdin through `security -i`, so the value never appears in a process list.
47
+ const command = `add-generic-password -U -s ${service(name)} -a ${userInfo().username} -w ${value}\n`;
48
+ return spawnSync('/usr/bin/security', ['-i'], { input: command, encoding: 'utf8' }).status === 0;
49
+ }
50
+ const path = secretsPath(configDir);
51
+ writeSecretsFile(path, { ...readSecretsFile(path), [name]: value });
52
+ return true;
53
+ }
54
+ export const readLinearKey = (configDir) => readSecret(configDir, 'linear');
55
+ export const storeLinearKey = (configDir, key) => looksLikeLinearKey(key) && storeSecret(configDir, 'linear', key);
@@ -0,0 +1,9 @@
1
+ import type { Check } from './types.ts';
2
+ export interface RunnerService {
3
+ loaded(): boolean;
4
+ start(home: string, program: string[], logPath: string): boolean;
5
+ stop(home: string): void;
6
+ programArgs(home: string): string[] | null;
7
+ checks(): Check[];
8
+ }
9
+ export declare const runnerService: (platform?: NodeJS.Platform) => RunnerService;
@@ -0,0 +1,18 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import * as launchd from "./launchd.js";
3
+ import * as systemd from "./systemd.js";
4
+ const mac = {
5
+ loaded: launchd.runnerLoaded,
6
+ start: launchd.startRunner,
7
+ stop: launchd.stopRunner,
8
+ programArgs: (home) => (existsSync(launchd.plistPath(home)) ? launchd.parsePlistArgs(readFileSync(launchd.plistPath(home), 'utf8')) : null),
9
+ checks: () => [],
10
+ };
11
+ const linux = {
12
+ loaded: systemd.runnerLoaded,
13
+ start: (home, program, logPath) => systemd.startRunner(home, program, launchd.runnerEnv(), logPath),
14
+ stop: systemd.stopRunner,
15
+ programArgs: systemd.programArgs,
16
+ checks: () => [systemd.lingerCheck()],
17
+ };
18
+ export const runnerService = (platform = process.platform) => (platform === 'darwin' ? mac : linux);
@@ -0,0 +1,14 @@
1
+ import type { Check } from './types.ts';
2
+ export declare const UNIT = "voidshift-agents";
3
+ export declare const servicePath: (home: string) => string;
4
+ export declare const timerPath: (home: string) => string;
5
+ export declare function quote(s: string): string;
6
+ export declare function unquote(s: string): string;
7
+ export declare function serviceUnit(program: string[], env: Record<string, string>, logPath: string): string;
8
+ export declare const timerUnit: () => string;
9
+ export declare function parseExecStart(unit: string): string[];
10
+ export declare const runnerLoaded: () => boolean;
11
+ export declare function startRunner(home: string, program: string[], env: Record<string, string>, logPath: string): boolean;
12
+ export declare function stopRunner(home: string): void;
13
+ export declare function programArgs(home: string): string[] | null;
14
+ export declare function lingerCheck(): Check;
@@ -0,0 +1,85 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { userInfo } from 'node:os';
4
+ import { dirname, join } from 'node:path';
5
+ // The runner on Linux: a systemd user service that runs one check, and a
6
+ // timer that starts it a minute after the last one finished.
7
+ export const UNIT = 'voidshift-agents';
8
+ const unitDir = (home) => join(process.env.XDG_CONFIG_HOME ?? join(home, '.config'), 'systemd', 'user');
9
+ export const servicePath = (home) => join(unitDir(home), `${UNIT}.service`);
10
+ export const timerPath = (home) => join(unitDir(home), `${UNIT}.timer`);
11
+ // `systemctl --user` needs the user's runtime dir, which a `sudo -iu` or `su`
12
+ // shell does not set.
13
+ function systemctl(args) {
14
+ const env = { ...process.env, XDG_RUNTIME_DIR: process.env.XDG_RUNTIME_DIR ?? `/run/user/${userInfo().uid}` };
15
+ const r = spawnSync('systemctl', ['--user', ...args], { encoding: 'utf8', env });
16
+ return { ok: r.status === 0, stdout: (r.stdout ?? '').trim() };
17
+ }
18
+ // One word of a unit file line: quoted, with the characters systemd expands
19
+ // escaped.
20
+ export function quote(s) {
21
+ return `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/%/g, '%%').replace(/\$/g, '$$$$')}"`;
22
+ }
23
+ export function unquote(s) {
24
+ return s.replace(/\$\$/g, '$').replace(/%%/g, '%').replace(/\\(.)/g, '$1');
25
+ }
26
+ export function serviceUnit(program, env, logPath) {
27
+ const vars = Object.entries(env).map(([k, v]) => `Environment=${quote(`${k}=${v}`)}`);
28
+ return `[Unit]
29
+ Description=VoidShift agents runner, one check for cards to work on
30
+
31
+ [Service]
32
+ Type=oneshot
33
+ ExecStart=${program.map(quote).join(' ')}
34
+ ${vars.join('\n')}
35
+ StandardOutput=append:${logPath}
36
+ StandardError=append:${logPath}
37
+ TimeoutStartSec=infinity
38
+ `;
39
+ }
40
+ export const timerUnit = () => `[Unit]
41
+ Description=Starts the VoidShift agents runner every minute
42
+
43
+ [Timer]
44
+ OnActiveSec=5s
45
+ OnUnitInactiveSec=60s
46
+ AccuracySec=1s
47
+
48
+ [Install]
49
+ WantedBy=timers.target
50
+ `;
51
+ // The words of a service unit's ExecStart line.
52
+ export function parseExecStart(unit) {
53
+ const line = /^ExecStart=(.*)$/m.exec(unit)?.[1] ?? '';
54
+ return [...line.matchAll(/"((?:\\.|[^"\\])*)"|(\S+)/g)].map((m) => (m[1] !== undefined ? unquote(m[1]) : m[2]));
55
+ }
56
+ export const runnerLoaded = () => systemctl(['is-active', '--quiet', `${UNIT}.timer`]).ok;
57
+ export function startRunner(home, program, env, logPath) {
58
+ mkdirSync(unitDir(home), { recursive: true });
59
+ mkdirSync(dirname(logPath), { recursive: true });
60
+ writeFileSync(servicePath(home), serviceUnit(program, env, logPath));
61
+ writeFileSync(timerPath(home), timerUnit());
62
+ if (!systemctl(['daemon-reload']).ok)
63
+ return false;
64
+ systemctl(['restart', `${UNIT}.timer`]);
65
+ return systemctl(['enable', '--now', `${UNIT}.timer`]).ok;
66
+ }
67
+ export function stopRunner(home) {
68
+ systemctl(['disable', '--now', `${UNIT}.timer`]);
69
+ systemctl(['stop', `${UNIT}.service`]);
70
+ rmSync(servicePath(home), { force: true });
71
+ rmSync(timerPath(home), { force: true });
72
+ systemctl(['daemon-reload']);
73
+ }
74
+ export function programArgs(home) {
75
+ return existsSync(servicePath(home)) ? parseExecStart(readFileSync(servicePath(home), 'utf8')) : null;
76
+ }
77
+ // Without lingering, systemd stops the user's timers when they log out and
78
+ // does not start them after a reboot.
79
+ export function lingerCheck() {
80
+ const user = userInfo().username;
81
+ const r = spawnSync('loginctl', ['show-user', user, '--property=Linger', '--value'], { encoding: 'utf8' });
82
+ return r.status === 0 && r.stdout.trim() === 'yes'
83
+ ? { name: 'runner after logout', status: 'ok', detail: 'lingering is on, the runner keeps going after logout and reboot' }
84
+ : { name: 'runner after logout', status: 'warn', detail: 'lingering is off, the runner stops when you log out', fix: `sudo loginctl enable-linger ${user}` };
85
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voidlabs.gg/voidshift-agents",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
4
4
  "description": "Agents for Claude Code and Codex that plan, build and QA Linear cards, chained and run in the background.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -18,7 +18,8 @@
18
18
  "agents",
19
19
  "linear",
20
20
  "cli",
21
- "macos"
21
+ "macos",
22
+ "linux"
22
23
  ],
23
24
  "type": "module",
24
25
  "main": "dist/index.js",
@@ -29,9 +30,6 @@
29
30
  "LICENSE",
30
31
  "README.md"
31
32
  ],
32
- "os": [
33
- "darwin"
34
- ],
35
33
  "engines": {
36
34
  "node": ">=22.18"
37
35
  },
@@ -1,3 +0,0 @@
1
- export declare function readLinearKey(): string | null;
2
- export declare const looksLikeLinearKey: (key: string) => boolean;
3
- export declare function storeLinearKey(key: string): boolean;
package/dist/keychain.js DELETED
@@ -1,19 +0,0 @@
1
- import { spawnSync } from 'node:child_process';
2
- import { userInfo } from 'node:os';
3
- const SERVICE = 'voidshift-linear';
4
- const KEY_SHAPE = /^lin_api_[A-Za-z0-9]+$/;
5
- export function readLinearKey() {
6
- const r = spawnSync('/usr/bin/security', ['find-generic-password', '-s', SERVICE, '-w'], { encoding: 'utf8' });
7
- const key = r.status === 0 ? r.stdout.trim() : '';
8
- return key || null;
9
- }
10
- export const looksLikeLinearKey = (key) => KEY_SHAPE.test(key);
11
- // Stores the key through `security -i` on stdin, so it never appears in a
12
- // process list. The shape check above rules out anything that could break
13
- // out of the command line.
14
- export function storeLinearKey(key) {
15
- if (!looksLikeLinearKey(key))
16
- return false;
17
- const command = `add-generic-password -U -s ${SERVICE} -a ${userInfo().username} -w ${key}\n`;
18
- return spawnSync('/usr/bin/security', ['-i'], { input: command, encoding: 'utf8' }).status === 0;
19
- }