@phnx-labs/agents-cli 1.20.90 → 1.20.92
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/CHANGELOG.md +276 -0
- package/README.md +1 -1
- package/dist/bin/agents +0 -0
- package/dist/commands/activity.d.ts +72 -6
- package/dist/commands/activity.js +198 -49
- package/dist/commands/beta.js +1 -0
- package/dist/commands/doctor.js +4 -2
- package/dist/commands/exec.d.ts +14 -0
- package/dist/commands/exec.js +144 -14
- package/dist/commands/feed.js +77 -4
- package/dist/commands/hooks.js +22 -6
- package/dist/commands/perf.d.ts +14 -0
- package/dist/commands/perf.js +221 -0
- package/dist/commands/projects.d.ts +12 -0
- package/dist/commands/projects.js +358 -0
- package/dist/commands/routines.js +30 -24
- package/dist/commands/secrets.d.ts +43 -4
- package/dist/commands/secrets.js +217 -32
- package/dist/commands/send.d.ts +5 -1
- package/dist/commands/send.js +1 -1
- package/dist/commands/sessions-picker.d.ts +15 -0
- package/dist/commands/sessions-picker.js +107 -13
- package/dist/commands/sessions-resume.d.ts +2 -0
- package/dist/commands/sessions-resume.js +9 -1
- package/dist/commands/sessions.d.ts +10 -5
- package/dist/commands/sessions.js +65 -27
- package/dist/index.js +19 -3
- package/dist/lib/activity.d.ts +80 -13
- package/dist/lib/activity.js +418 -74
- package/dist/lib/beta.d.ts +1 -1
- package/dist/lib/beta.js +1 -1
- package/dist/lib/catchup.d.ts +105 -0
- package/dist/lib/catchup.js +160 -0
- package/dist/lib/channels/providers/desktop.d.ts +49 -0
- package/dist/lib/channels/providers/desktop.js +132 -0
- package/dist/lib/channels/providers/index.js +2 -0
- package/dist/lib/daemon.js +74 -13
- package/dist/lib/devices/registry.d.ts +14 -0
- package/dist/lib/devices/registry.js +37 -0
- package/dist/lib/events.d.ts +12 -0
- package/dist/lib/events.js +122 -9
- package/dist/lib/exec.js +10 -0
- package/dist/lib/feed-broadcast.d.ts +47 -0
- package/dist/lib/feed-broadcast.js +65 -1
- package/dist/lib/feed-post.d.ts +10 -0
- package/dist/lib/feed-post.js +9 -3
- package/dist/lib/feed.d.ts +47 -1
- package/dist/lib/feed.js +38 -0
- package/dist/lib/hooks/cache.d.ts +2 -0
- package/dist/lib/hooks/cache.js +24 -4
- package/dist/lib/hosts/remote-cmd.js +4 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/menubar/install-menubar.d.ts +14 -4
- package/dist/lib/menubar/install-menubar.js +20 -6
- package/dist/lib/overdue.d.ts +14 -0
- package/dist/lib/overdue.js +37 -1
- package/dist/lib/perf/db.d.ts +25 -0
- package/dist/lib/perf/db.js +290 -0
- package/dist/lib/perf/spool.d.ts +18 -0
- package/dist/lib/perf/spool.js +79 -0
- package/dist/lib/perf/types.d.ts +45 -0
- package/dist/lib/perf/types.js +2 -0
- package/dist/lib/project-key.d.ts +44 -0
- package/dist/lib/project-key.js +79 -0
- package/dist/lib/project-root.js +16 -0
- package/dist/lib/project-status.d.ts +69 -0
- package/dist/lib/project-status.js +101 -0
- package/dist/lib/projects.d.ts +138 -0
- package/dist/lib/projects.js +301 -0
- package/dist/lib/remote-agents-json.d.ts +9 -0
- package/dist/lib/remote-agents-json.js +11 -5
- package/dist/lib/routines-project.js +6 -0
- package/dist/lib/routines.d.ts +30 -1
- package/dist/lib/routines.js +11 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/list-filter.d.ts +94 -0
- package/dist/lib/secrets/list-filter.js +245 -0
- package/dist/lib/session/bash-command.d.ts +53 -0
- package/dist/lib/session/bash-command.js +364 -0
- package/dist/lib/session/digest.d.ts +13 -0
- package/dist/lib/session/digest.js +48 -1
- package/dist/lib/session/discover.d.ts +1 -2
- package/dist/lib/session/discover.js +7 -24
- package/dist/lib/session/highlights.d.ts +82 -0
- package/dist/lib/session/highlights.js +251 -0
- package/dist/lib/session/parse.js +23 -1
- package/dist/lib/session/relative-time.d.ts +37 -0
- package/dist/lib/session/relative-time.js +96 -8
- package/dist/lib/session/remote-list.js +5 -2
- package/dist/lib/session/render.d.ts +9 -9
- package/dist/lib/session/render.js +112 -73
- package/dist/lib/session/types.d.ts +4 -1
- package/dist/lib/ssh-exec.d.ts +6 -0
- package/dist/lib/ssh-exec.js +10 -1
- package/dist/lib/startup/command-registry.d.ts +2 -0
- package/dist/lib/startup/command-registry.js +4 -0
- package/dist/lib/state.d.ts +11 -0
- package/dist/lib/state.js +16 -0
- package/dist/lib/terminal/backends/index.d.ts +10 -2
- package/dist/lib/terminal/backends/index.js +14 -2
- package/dist/lib/terminal/backends/terminal-app.d.ts +13 -0
- package/dist/lib/terminal/backends/terminal-app.js +73 -0
- package/dist/lib/terminal/index.d.ts +2 -1
- package/dist/lib/terminal/index.js +2 -1
- package/dist/lib/terminal/preferred.d.ts +89 -0
- package/dist/lib/terminal/preferred.js +87 -0
- package/dist/lib/terminal/run-surface.d.ts +82 -0
- package/dist/lib/terminal/run-surface.js +146 -0
- package/dist/lib/terminal/types.d.ts +1 -1
- package/dist/lib/types.d.ts +1 -1
- package/package.json +4 -1
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Named project definitions — the layer above the `--project <slug>` convention.
|
|
3
|
+
*
|
|
4
|
+
* `agents run --project <slug>` already resolves a bare name to a working
|
|
5
|
+
* directory by pure convention (`<projectRoot>/<slug>`, see `project-root.ts`).
|
|
6
|
+
* This module adds editable definitions on top: one YAML file per project under
|
|
7
|
+
* `~/.agents/projects/<name>.yaml`, sitting beside the existing `routines/`,
|
|
8
|
+
* `monitors/`, and `teams/` dirs in the user repo (so definitions sync across
|
|
9
|
+
* machines for free via `agents push/pull`). A defined project can name itself
|
|
10
|
+
* independently of its folder, bind more than one repo, pin a monorepo subpath,
|
|
11
|
+
* describe context subdirectories an agent should start from, carry a Linear
|
|
12
|
+
* link and external integrations, and set an explicit default path.
|
|
13
|
+
*
|
|
14
|
+
* Portable by construction: `root`/`defaultPath` are stored home-relative
|
|
15
|
+
* (`~/…`) via `toHomeRelative`, so the same definition re-roots on any machine
|
|
16
|
+
* whose home differs — the exact mechanism `project-root.ts` already relies on.
|
|
17
|
+
*
|
|
18
|
+
* Resolution stays additive: an undefined slug still resolves exactly as today
|
|
19
|
+
* (see `resolveProjectRef`), a defined one overrides it.
|
|
20
|
+
*/
|
|
21
|
+
import * as fs from 'fs';
|
|
22
|
+
import * as path from 'path';
|
|
23
|
+
import * as yaml from 'yaml';
|
|
24
|
+
import { getProjectsDir } from './state.js';
|
|
25
|
+
import { safeJoin } from './paths.js';
|
|
26
|
+
import { toHomeRelative, expandLocalHome } from './project-root.js';
|
|
27
|
+
import { resolveProjectKey } from './project-key.js';
|
|
28
|
+
/** A project name safe to use as a filename: no separators, `..`, or leading dot. */
|
|
29
|
+
export function isSafeProjectName(name) {
|
|
30
|
+
return (typeof name === 'string' &&
|
|
31
|
+
name.length > 0 &&
|
|
32
|
+
name.length <= 64 &&
|
|
33
|
+
/^[a-z0-9][a-z0-9._-]*$/i.test(name) &&
|
|
34
|
+
name !== '.' &&
|
|
35
|
+
name !== '..');
|
|
36
|
+
}
|
|
37
|
+
/** Absolute path to a project's YAML definition. Throws on an unsafe name. */
|
|
38
|
+
export function projectDefPath(name) {
|
|
39
|
+
if (!isSafeProjectName(name)) {
|
|
40
|
+
throw new Error(`Invalid project name: "${name}" (letters, digits, ., _, - only)`);
|
|
41
|
+
}
|
|
42
|
+
return safeJoin(getProjectsDir(), `${name}.yaml`);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Validate a raw parsed object into a `ProjectDef`, throwing an actionable error
|
|
46
|
+
* on the first problem. A malformed document or identity (bad/mismatched name)
|
|
47
|
+
* throws; malformed entries inside the optional lists (`repos`/`contexts`/
|
|
48
|
+
* `integrations`) are dropped so one bad row can't sink an otherwise good def.
|
|
49
|
+
*/
|
|
50
|
+
export function validateProjectDef(raw, sourceName) {
|
|
51
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
52
|
+
throw new Error(`Project ${sourceName ?? ''} is not a YAML mapping`.trim());
|
|
53
|
+
}
|
|
54
|
+
const o = raw;
|
|
55
|
+
// The filename is the identity; a `name:` field is optional but, when present,
|
|
56
|
+
// must be a valid slug — a malformed one is a loud error, not a silent fallback.
|
|
57
|
+
const hasNameField = 'name' in o && o.name !== undefined && o.name !== null;
|
|
58
|
+
let name;
|
|
59
|
+
if (hasNameField) {
|
|
60
|
+
if (typeof o.name !== 'string' || !isSafeProjectName(o.name)) {
|
|
61
|
+
throw new Error(`Project ${sourceName ?? ''}: "name" must be a valid slug (got ${JSON.stringify(o.name)})`);
|
|
62
|
+
}
|
|
63
|
+
name = o.name;
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
name = sourceName;
|
|
67
|
+
}
|
|
68
|
+
if (!name || !isSafeProjectName(name)) {
|
|
69
|
+
throw new Error('Project definition is missing a valid "name"');
|
|
70
|
+
}
|
|
71
|
+
// The filename IS the stable id — a def whose `name:` disagrees with its
|
|
72
|
+
// filename would resolve under one name and list under another.
|
|
73
|
+
if (hasNameField && sourceName && name !== sourceName) {
|
|
74
|
+
throw new Error(`Project ${sourceName}: "name" (${JSON.stringify(name)}) must match the filename — the filename is the stable id`);
|
|
75
|
+
}
|
|
76
|
+
const def = { name };
|
|
77
|
+
if (typeof o.description === 'string')
|
|
78
|
+
def.description = o.description;
|
|
79
|
+
if (typeof o.root === 'string')
|
|
80
|
+
def.root = o.root;
|
|
81
|
+
if (typeof o.defaultPath === 'string')
|
|
82
|
+
def.defaultPath = o.defaultPath;
|
|
83
|
+
if (typeof o.repo === 'string')
|
|
84
|
+
def.repo = o.repo;
|
|
85
|
+
if (Array.isArray(o.repos)) {
|
|
86
|
+
def.repos = o.repos.flatMap((r) => {
|
|
87
|
+
if (r && typeof r === 'object' && typeof r.slug === 'string') {
|
|
88
|
+
const rr = r;
|
|
89
|
+
const repo = { slug: rr.slug };
|
|
90
|
+
if (typeof rr.subpath === 'string')
|
|
91
|
+
repo.subpath = rr.subpath;
|
|
92
|
+
return [repo];
|
|
93
|
+
}
|
|
94
|
+
return [];
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
if (Array.isArray(o.contexts)) {
|
|
98
|
+
def.contexts = o.contexts.flatMap((c) => {
|
|
99
|
+
if (c &&
|
|
100
|
+
typeof c === 'object' &&
|
|
101
|
+
typeof c.path === 'string' &&
|
|
102
|
+
typeof c.purpose === 'string') {
|
|
103
|
+
const cc = c;
|
|
104
|
+
return [{ path: cc.path, purpose: cc.purpose }];
|
|
105
|
+
}
|
|
106
|
+
return [];
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
if (Array.isArray(o.integrations)) {
|
|
110
|
+
def.integrations = o.integrations.flatMap((i) => {
|
|
111
|
+
if (i &&
|
|
112
|
+
typeof i === 'object' &&
|
|
113
|
+
typeof i.kind === 'string' &&
|
|
114
|
+
typeof i.url === 'string') {
|
|
115
|
+
const ii = i;
|
|
116
|
+
const integ = { kind: ii.kind, url: ii.url };
|
|
117
|
+
if (typeof ii.label === 'string')
|
|
118
|
+
integ.label = ii.label;
|
|
119
|
+
return [integ];
|
|
120
|
+
}
|
|
121
|
+
return [];
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
if (o.linear && typeof o.linear === 'object' && !Array.isArray(o.linear)) {
|
|
125
|
+
const l = o.linear;
|
|
126
|
+
def.linear = {};
|
|
127
|
+
if (typeof l.projectId === 'string')
|
|
128
|
+
def.linear.projectId = l.projectId;
|
|
129
|
+
if (typeof l.url === 'string')
|
|
130
|
+
def.linear.url = l.url;
|
|
131
|
+
}
|
|
132
|
+
if (Array.isArray(o.docs))
|
|
133
|
+
def.docs = o.docs.filter((d) => typeof d === 'string');
|
|
134
|
+
return def;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Load a single project definition by name. Returns undefined when the file is
|
|
138
|
+
* absent (the common "not a defined project, fall back to convention" case) but
|
|
139
|
+
* throws when a file EXISTS and is malformed — a broken definition is loud.
|
|
140
|
+
*/
|
|
141
|
+
export function loadProjectDef(name) {
|
|
142
|
+
if (!isSafeProjectName(name))
|
|
143
|
+
return undefined;
|
|
144
|
+
let raw;
|
|
145
|
+
try {
|
|
146
|
+
raw = fs.readFileSync(projectDefPath(name), 'utf8');
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
return undefined; // absent — not a defined project
|
|
150
|
+
}
|
|
151
|
+
return validateProjectDef(yaml.parse(raw), name);
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* List every defined project, sorted by name. Skips (does not throw on) a
|
|
155
|
+
* malformed file so one bad definition can't break `projects list`; the loader
|
|
156
|
+
* for a single named project stays strict.
|
|
157
|
+
*/
|
|
158
|
+
export function listProjectDefs() {
|
|
159
|
+
let files;
|
|
160
|
+
try {
|
|
161
|
+
// Definitions are `<name>.yaml` (what projectDefPath/loadProjectDef read). We
|
|
162
|
+
// deliberately do NOT list `.yml` here — accepting it would then ENOENT in the
|
|
163
|
+
// loader and silently drop the project. One extension, one code path.
|
|
164
|
+
files = fs.readdirSync(getProjectsDir()).filter((f) => f.endsWith('.yaml'));
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
return [];
|
|
168
|
+
}
|
|
169
|
+
const out = [];
|
|
170
|
+
for (const f of files) {
|
|
171
|
+
const name = f.replace(/\.yaml$/, '');
|
|
172
|
+
try {
|
|
173
|
+
const def = loadProjectDef(name);
|
|
174
|
+
if (def)
|
|
175
|
+
out.push(def);
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
/* malformed — skip in the listing */
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return out.sort((a, b) => a.name.localeCompare(b.name));
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Persist a project definition, normalizing `root`/`defaultPath` to home-relative
|
|
185
|
+
* so it stays portable across machines. Creates the projects dir on first write.
|
|
186
|
+
*/
|
|
187
|
+
export function writeProjectDef(def) {
|
|
188
|
+
const validated = validateProjectDef(def, def.name);
|
|
189
|
+
const normalized = {
|
|
190
|
+
...validated,
|
|
191
|
+
root: validated.root ? toHomeRelative(expandLocalHome(validated.root)) : undefined,
|
|
192
|
+
defaultPath: validated.defaultPath
|
|
193
|
+
? toHomeRelative(expandLocalHome(validated.defaultPath))
|
|
194
|
+
: undefined,
|
|
195
|
+
};
|
|
196
|
+
// Drop undefined keys so the YAML stays clean.
|
|
197
|
+
const clean = Object.fromEntries(Object.entries(normalized).filter(([, v]) => v !== undefined));
|
|
198
|
+
const target = projectDefPath(def.name);
|
|
199
|
+
fs.mkdirSync(getProjectsDir(), { recursive: true });
|
|
200
|
+
fs.writeFileSync(target, yaml.stringify(clean), 'utf8');
|
|
201
|
+
return target;
|
|
202
|
+
}
|
|
203
|
+
/** Delete a project definition. Returns true if a file was removed. Never touches the repo. */
|
|
204
|
+
export function removeProjectDef(name) {
|
|
205
|
+
try {
|
|
206
|
+
fs.unlinkSync(projectDefPath(name));
|
|
207
|
+
return true;
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
return false;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* The cwd an agent lands in for a defined project: `defaultPath` when set, else
|
|
215
|
+
* `root`. Home-relative when `forRemote` (the remote shell expands `~`), else
|
|
216
|
+
* expanded against the local home. Returns undefined when neither is set.
|
|
217
|
+
*/
|
|
218
|
+
export function projectBasePath(def, forRemote) {
|
|
219
|
+
const base = def.defaultPath ?? def.root;
|
|
220
|
+
if (!base)
|
|
221
|
+
return undefined;
|
|
222
|
+
return forRemote ? base : expandLocalHome(base);
|
|
223
|
+
}
|
|
224
|
+
function projectRootsAbs(defs) {
|
|
225
|
+
const out = [];
|
|
226
|
+
for (const def of defs) {
|
|
227
|
+
const raw = def.root ?? def.defaultPath;
|
|
228
|
+
if (!raw)
|
|
229
|
+
continue;
|
|
230
|
+
out.push({ name: def.name, abs: path.resolve(expandLocalHome(raw)) });
|
|
231
|
+
}
|
|
232
|
+
return out;
|
|
233
|
+
}
|
|
234
|
+
/** True when `child` is `parent` or nested under it (path-segment aware). */
|
|
235
|
+
function isUnder(child, parent) {
|
|
236
|
+
if (child === parent)
|
|
237
|
+
return true;
|
|
238
|
+
const withSep = parent.endsWith(path.sep) ? parent : parent + path.sep;
|
|
239
|
+
return child.startsWith(withSep);
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Which defined project a session belongs to, derived from its working
|
|
243
|
+
* directory. A session whose `cwd` sits inside a project's repo root (or a
|
|
244
|
+
* worktree under it) is a member; the LONGEST matching root wins so a nested
|
|
245
|
+
* project beats its parent. Returns undefined when no definition contains the
|
|
246
|
+
* path.
|
|
247
|
+
*
|
|
248
|
+
* The comparison is against the LOCAL home: roots and the cwd are both expanded
|
|
249
|
+
* with `expandLocalHome` and resolved, so this matches sessions whose cwd shares
|
|
250
|
+
* this machine's home layout. A session recorded on a different-home machine
|
|
251
|
+
* (`/Users/x/…` vs `/home/x/…`) will not match until the fleet-wide,
|
|
252
|
+
* home-relative variant lands (see the deferred item in docs/11-projects.md).
|
|
253
|
+
*/
|
|
254
|
+
export function projectNameForCwd(cwd, defs) {
|
|
255
|
+
if (!cwd)
|
|
256
|
+
return undefined;
|
|
257
|
+
const abs = path.resolve(expandLocalHome(cwd));
|
|
258
|
+
let best;
|
|
259
|
+
let bestLen = -1;
|
|
260
|
+
for (const { name, abs: root } of projectRootsAbs(defs)) {
|
|
261
|
+
if (isUnder(abs, root) && root.length > bestLen) {
|
|
262
|
+
best = name;
|
|
263
|
+
bestLen = root.length;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return best;
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* The canonical project label for a cwd, for every surface that buckets work by
|
|
270
|
+
* project (the activity timeline, feed posts, the sessions overview): the
|
|
271
|
+
* DEFINED project whose root contains the cwd (longest root wins, so a
|
|
272
|
+
* multi-repo project reads as one bucket), else the repository-level key from
|
|
273
|
+
* {@link resolveProjectKey}. `defs` comes from {@link listProjectDefs}, which is
|
|
274
|
+
* fail-open — with no definitions this degrades to exactly today's behavior.
|
|
275
|
+
*/
|
|
276
|
+
export function resolveProjectNameForCwd(cwd, defs) {
|
|
277
|
+
if (!cwd)
|
|
278
|
+
return undefined;
|
|
279
|
+
return projectNameForCwd(cwd, defs) ?? resolveProjectKey(cwd);
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Resolve a defined project's ref to a working directory, mirroring
|
|
283
|
+
* `buildProjectPath`'s `forRemote` contract (a home-relative `~/…` for the
|
|
284
|
+
* remote shell to expand, an absolute local path otherwise). A `@worktree`
|
|
285
|
+
* lands under the repo ROOT's `.agents/worktrees/`, not the `defaultPath`
|
|
286
|
+
* subdir — worktrees are per-repo, not per-focus. Returns undefined when the
|
|
287
|
+
* definition carries no `root`/`defaultPath` (caller falls back to convention).
|
|
288
|
+
*/
|
|
289
|
+
export function resolveDefinedProjectPath(def, worktree, forRemote) {
|
|
290
|
+
if (worktree) {
|
|
291
|
+
const rootRaw = def.root ?? def.defaultPath;
|
|
292
|
+
if (!rootRaw)
|
|
293
|
+
return undefined;
|
|
294
|
+
const wt = `${rootRaw}/.agents/worktrees/${worktree}`;
|
|
295
|
+
return forRemote ? wt : path.resolve(expandLocalHome(wt));
|
|
296
|
+
}
|
|
297
|
+
const base = projectBasePath(def, forRemote);
|
|
298
|
+
if (!base)
|
|
299
|
+
return undefined;
|
|
300
|
+
return forRemote ? base : path.resolve(base);
|
|
301
|
+
}
|
|
@@ -3,10 +3,19 @@ export interface RemoteAgentsJsonOptions<T> {
|
|
|
3
3
|
noFanoutEnv: string;
|
|
4
4
|
hosts?: string[];
|
|
5
5
|
parse: (stdout: string, machine: string) => T[];
|
|
6
|
+
/**
|
|
7
|
+
* Suppress the per-device "unreachable — skipped" stderr line. The skipped
|
|
8
|
+
* names still come back in {@link RemoteAgentsJsonResult.skipped}, so a caller
|
|
9
|
+
* that fans out by DEFAULT can report them once, compactly, instead of
|
|
10
|
+
* printing a line per offline box above its output. Never a silent drop.
|
|
11
|
+
*/
|
|
12
|
+
quiet?: boolean;
|
|
6
13
|
}
|
|
7
14
|
export interface RemoteAgentsJsonResult<T> {
|
|
8
15
|
items: T[];
|
|
9
16
|
deviceCount: number;
|
|
17
|
+
/** Devices that were dialed but answered with an error / no CLI / a timeout. */
|
|
18
|
+
skipped: string[];
|
|
10
19
|
}
|
|
11
20
|
/** Build the command one peer runs, with a guard that prevents recursive fan-out. */
|
|
12
21
|
export declare function remoteAgentsJsonCommand(args: string[], noFanoutEnv: string, os?: string): string;
|
|
@@ -11,7 +11,7 @@ import chalk from 'chalk';
|
|
|
11
11
|
import { SSH_OPTS, controlOpts, assertValidSshTarget, shellQuote } from './ssh-exec.js';
|
|
12
12
|
import { sshTargetFor } from './devices/connect.js';
|
|
13
13
|
import { resolveExplicitTargets } from './devices/resolve-target.js';
|
|
14
|
-
import { loadDevices, isControlDevice } from './devices/registry.js';
|
|
14
|
+
import { loadDevices, isControlDevice, isDialableDevice } from './devices/registry.js';
|
|
15
15
|
import { remoteShellFor, buildWindowsAgentsCommand } from './hosts/remote-cmd.js';
|
|
16
16
|
import { machineId, normalizeHost } from './machine-id.js';
|
|
17
17
|
const REMOTE_TIMEOUT_MS = 12_000;
|
|
@@ -59,10 +59,12 @@ export async function gatherRemoteAgentsJson(options) {
|
|
|
59
59
|
devices = await loadDevices();
|
|
60
60
|
}
|
|
61
61
|
catch {
|
|
62
|
-
return { items: [], deviceCount: 0 };
|
|
62
|
+
return { items: [], deviceCount: 0, skipped: [] };
|
|
63
63
|
}
|
|
64
64
|
for (const device of Object.values(devices)) {
|
|
65
|
-
|
|
65
|
+
// Live SSH-probe verdict first, cached tailscale snapshot only as a
|
|
66
|
+
// fallback — see isDialableDevice (mirrors session/remote-list.ts).
|
|
67
|
+
if (!isDialableDevice(device))
|
|
66
68
|
continue;
|
|
67
69
|
if (normalizeHost(device.name) === self)
|
|
68
70
|
continue;
|
|
@@ -87,14 +89,18 @@ export async function gatherRemoteAgentsJson(options) {
|
|
|
87
89
|
}
|
|
88
90
|
}
|
|
89
91
|
}
|
|
92
|
+
const skipped = [];
|
|
90
93
|
const results = await Promise.all(targets.map(async (target) => {
|
|
91
94
|
const command = remoteAgentsJsonCommand(options.args, options.noFanoutEnv, target.os);
|
|
92
95
|
const result = await sshCapture(target.target, command);
|
|
93
96
|
if (result.code !== 0) {
|
|
94
|
-
|
|
97
|
+
skipped.push(target.name);
|
|
98
|
+
if (!options.quiet) {
|
|
99
|
+
process.stderr.write(chalk.gray(` ${target.name}: unreachable or no agents CLI — skipped\n`));
|
|
100
|
+
}
|
|
95
101
|
return [];
|
|
96
102
|
}
|
|
97
103
|
return options.parse(result.stdout, target.machine);
|
|
98
104
|
}));
|
|
99
|
-
return { items: results.flat(), deviceCount: targets.length };
|
|
105
|
+
return { items: results.flat(), deviceCount: targets.length, skipped };
|
|
100
106
|
}
|
|
@@ -269,6 +269,12 @@ export function syncProjectRoutines(projectRoot) {
|
|
|
269
269
|
if (job.devices === undefined && existing.devices && existing.devices.length > 0) {
|
|
270
270
|
job.devices = existing.devices;
|
|
271
271
|
}
|
|
272
|
+
// Carry the original creation stamp across. A sync rebuilds the config
|
|
273
|
+
// from the PROJECT yaml, which never carries `createdAt`, so without this
|
|
274
|
+
// every `agents routines sync` would re-stamp it to now — walking the
|
|
275
|
+
// overdue floor forward and hiding real missed fires for project routines.
|
|
276
|
+
if (existing.createdAt)
|
|
277
|
+
job.createdAt = existing.createdAt;
|
|
272
278
|
}
|
|
273
279
|
// Placement that leaves the firing machine must pin devices to avoid
|
|
274
280
|
// every fleet daemon dispatching once.
|
package/dist/lib/routines.d.ts
CHANGED
|
@@ -122,6 +122,28 @@ export interface JobConfig {
|
|
|
122
122
|
* overdue; everywhere else it is inert and `run` refuses with a pointer.
|
|
123
123
|
*/
|
|
124
124
|
devices?: string[];
|
|
125
|
+
/**
|
|
126
|
+
* Whether a fire this device missed (daemon down, laptop asleep, wedged event
|
|
127
|
+
* loop) is run late. Defaults to true: croner only schedules forward from
|
|
128
|
+
* "now", so without catch-up a missed fire is simply lost and the routine
|
|
129
|
+
* silently does not run.
|
|
130
|
+
*
|
|
131
|
+
* Set `catchup: false` for a routine whose value is tied to its clock — a
|
|
132
|
+
* 9am standup brief is worthless at 3pm. An opted-out routine still records
|
|
133
|
+
* the miss (a `missed` run), it just is not re-run.
|
|
134
|
+
*/
|
|
135
|
+
catchup?: boolean;
|
|
136
|
+
/**
|
|
137
|
+
* When this routine came into existence, ISO 8601. Stamped once by
|
|
138
|
+
* {@link writeJob}, like `actor`.
|
|
139
|
+
*
|
|
140
|
+
* Overdue detection needs it: `detectOverdueJobs` walks back a week for the
|
|
141
|
+
* most recent expected fire, so without a floor a brand-new routine is
|
|
142
|
+
* "overdue" for occurrences that happened before it was written. Harmless
|
|
143
|
+
* when catch-up was a manual command; with auto-catchup it would run every
|
|
144
|
+
* newly created routine once, immediately.
|
|
145
|
+
*/
|
|
146
|
+
createdAt?: string;
|
|
125
147
|
/**
|
|
126
148
|
* Environment variables injected into the spawned run, on top of the sandbox
|
|
127
149
|
* overlay's own. Merged by `buildSpawnEnv`, so it applies to both the
|
|
@@ -217,7 +239,14 @@ export interface RunMeta {
|
|
|
217
239
|
pid: number | null;
|
|
218
240
|
/** Process birth time (epoch ms) recorded at spawn for pid-reuse detection. */
|
|
219
241
|
spawnedAt?: number;
|
|
220
|
-
|
|
242
|
+
/**
|
|
243
|
+
* `missed` is not an execution outcome — it is the record that a scheduled
|
|
244
|
+
* fire never happened (the daemon was down, asleep, or wedged when it came
|
|
245
|
+
* due). Without it a miss leaves no trace at all and the listing keeps
|
|
246
|
+
* showing the previous run's status as if it were current. Written by
|
|
247
|
+
* `claimMissedFire` (catchup.ts), never by the runner.
|
|
248
|
+
*/
|
|
249
|
+
status: 'running' | 'completed' | 'failed' | 'timeout' | 'missed';
|
|
221
250
|
startedAt: string;
|
|
222
251
|
completedAt: string | null;
|
|
223
252
|
exitCode: number | null;
|
package/dist/lib/routines.js
CHANGED
|
@@ -274,6 +274,12 @@ export function writeJob(config) {
|
|
|
274
274
|
// only a brand-new routine (no actor yet) gets the current resolver.
|
|
275
275
|
if (!config.actor)
|
|
276
276
|
config.actor = resolveActor().id;
|
|
277
|
+
// Stamped once, on first write, and preserved by every later edit (an edit
|
|
278
|
+
// re-writes a config loaded from disk, which already carries it). This is the
|
|
279
|
+
// floor overdue detection uses so a routine is never judged against fires
|
|
280
|
+
// that predate it.
|
|
281
|
+
if (!config.createdAt)
|
|
282
|
+
config.createdAt = new Date().toISOString();
|
|
277
283
|
const jobsDir = getRoutinesDir();
|
|
278
284
|
const ymlPath = safeJoin(jobsDir, config.name + '.yml');
|
|
279
285
|
const yamlPath = safeJoin(jobsDir, config.name + '.yaml');
|
|
@@ -294,6 +300,8 @@ export function writeJob(config) {
|
|
|
294
300
|
delete output.enabled;
|
|
295
301
|
if (output.runOnce === false || output.runOnce === undefined)
|
|
296
302
|
delete output.runOnce;
|
|
303
|
+
if (output.catchup === true || output.catchup === undefined)
|
|
304
|
+
delete output.catchup;
|
|
297
305
|
const devArr = output.devices;
|
|
298
306
|
if (!devArr || devArr.length === 0)
|
|
299
307
|
delete output.devices;
|
|
@@ -515,6 +523,9 @@ export function validateJob(config) {
|
|
|
515
523
|
}
|
|
516
524
|
}
|
|
517
525
|
}
|
|
526
|
+
if (config.catchup !== undefined && typeof config.catchup !== 'boolean') {
|
|
527
|
+
errors.push('catchup must be a boolean (false to skip running a missed fire late)');
|
|
528
|
+
}
|
|
518
529
|
// Off-box placement without a devices pin fires on every fleet daemon and
|
|
519
530
|
// each dispatches once (RUSH-1980). Enforce the pin at validation so hand
|
|
520
531
|
// edits and devices --clear cannot re-open the hole.
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Filters for `agents secrets list`.
|
|
3
|
+
*
|
|
4
|
+
* The listing had no filtering at all: `--host`/`--device` pick a machine and
|
|
5
|
+
* `--json` picks a format, but nothing selected over the bundles themselves. On
|
|
6
|
+
* a fleet with fifty-odd bundles the answerable questions — which ones read with
|
|
7
|
+
* no Touch ID at all, which hold a raw literal, which have already expired, what
|
|
8
|
+
* has not been touched in three months — meant piping the table through grep, or
|
|
9
|
+
* were simply unanswerable.
|
|
10
|
+
*
|
|
11
|
+
* Everything here is pure: parsing produces a `SecretsListFilter`, and
|
|
12
|
+
* `bundleMatchesFilter` is a predicate over a bundle plus the ambient facts it
|
|
13
|
+
* cannot derive itself (which bundles the broker currently holds, and the
|
|
14
|
+
* current time). That keeps the whole surface unit-testable without a keychain.
|
|
15
|
+
*
|
|
16
|
+
* Shape follows the `agents sessions` house style: comma-separated lists, an
|
|
17
|
+
* unknown value is a loud error naming the valid set (never a silent ignore),
|
|
18
|
+
* and every axis narrows independently so they AND-compose.
|
|
19
|
+
*/
|
|
20
|
+
import { type SecretsBundle, type SecretsPolicy, type SecretsBackend, type SecretType } from './bundles.js';
|
|
21
|
+
/** Ref kinds a var's value can have, from `describeBundle`. */
|
|
22
|
+
export declare const REF_KINDS: readonly ["literal", "keychain", "env", "file", "exec"];
|
|
23
|
+
export type RefKind = typeof REF_KINDS[number];
|
|
24
|
+
/** Default window for `--expiring` with no argument — matches the EXPIRING column. */
|
|
25
|
+
export declare const DEFAULT_EXPIRING_DAYS = 30;
|
|
26
|
+
/** A parsed, validated filter. Every field is optional; absent ⇒ that axis does
|
|
27
|
+
* not narrow. All present axes must match (AND). */
|
|
28
|
+
export interface SecretsListFilter {
|
|
29
|
+
/** Case-insensitive substring over bundle name and description. */
|
|
30
|
+
query?: string;
|
|
31
|
+
policy?: SecretsPolicy[];
|
|
32
|
+
backend?: SecretsBackend[];
|
|
33
|
+
type?: SecretType[];
|
|
34
|
+
kind?: RefKind[];
|
|
35
|
+
/** true ⇒ only bundles the broker holds; false ⇒ only those it does not. */
|
|
36
|
+
held?: boolean;
|
|
37
|
+
/** Only bundles with at least one var whose `expires` is already past. */
|
|
38
|
+
expired?: boolean;
|
|
39
|
+
/** Only bundles with at least one var expiring within this many days. */
|
|
40
|
+
expiringDays?: number;
|
|
41
|
+
/** Only bundles whose `last_used` is older than this epoch-ms (or never used). */
|
|
42
|
+
unusedBefore?: number;
|
|
43
|
+
}
|
|
44
|
+
/** The raw option bag commander hands us. */
|
|
45
|
+
export interface SecretsListFilterOpts {
|
|
46
|
+
policy?: string;
|
|
47
|
+
backend?: string;
|
|
48
|
+
type?: string;
|
|
49
|
+
kind?: string;
|
|
50
|
+
held?: boolean;
|
|
51
|
+
notHeld?: boolean;
|
|
52
|
+
expired?: boolean;
|
|
53
|
+
expiring?: string | boolean;
|
|
54
|
+
unused?: string;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Validate one comma-separated enum list. An unknown value throws and names the
|
|
58
|
+
* whole valid set — a silent ignore would let `--policy hodl` quietly return
|
|
59
|
+
* every bundle, which reads as "nothing matches that" and is worse than an error.
|
|
60
|
+
* Values are lowercased, matching `parsePolicyOpt`'s handling of policy names.
|
|
61
|
+
*/
|
|
62
|
+
export declare function parseEnumList<T extends string>(raw: string, flag: string, valid: readonly T[]): T[];
|
|
63
|
+
/** Build a validated filter from commander's option bag. Throws on bad input. */
|
|
64
|
+
export declare function parseListFilters(opts: SecretsListFilterOpts, query?: string): SecretsListFilter;
|
|
65
|
+
/** True when any axis is set — used to decide whether the empty state should
|
|
66
|
+
* explain itself rather than claim there are no bundles at all. */
|
|
67
|
+
export declare function filterIsActive(f: SecretsListFilter): boolean;
|
|
68
|
+
/** Expiry tallies for one bundle: how many vars are already past, and how many
|
|
69
|
+
* fall due within `withinDays`. */
|
|
70
|
+
export declare function bundleExpiry(b: SecretsBundle, now: number, withinDays?: number): {
|
|
71
|
+
expired: number;
|
|
72
|
+
soon: number;
|
|
73
|
+
};
|
|
74
|
+
/** Ambient facts a bundle can't answer about itself. */
|
|
75
|
+
export interface FilterContext {
|
|
76
|
+
/** Bundle name → hold expiry epoch-ms, from the broker. Empty off macOS. */
|
|
77
|
+
held: Map<string, number>;
|
|
78
|
+
now: number;
|
|
79
|
+
}
|
|
80
|
+
/** Does this bundle satisfy every set axis? Pure. */
|
|
81
|
+
export declare function bundleMatchesFilter(b: SecretsBundle, f: SecretsListFilter, ctx: FilterContext): boolean;
|
|
82
|
+
/** Sort fields for `--sort`. `name` is the default and matches `listBundles()`. */
|
|
83
|
+
export declare const SORT_FIELDS: readonly ["name", "used", "created", "updated", "expiry"];
|
|
84
|
+
export type SortField = typeof SORT_FIELDS[number];
|
|
85
|
+
export declare function parseSortField(raw: string | undefined): SortField;
|
|
86
|
+
/** Sort a copy. Time fields are most-recent-first (the useful direction for
|
|
87
|
+
* "what did I touch lately"); `expiry` is soonest-first; ties fall back to name
|
|
88
|
+
* so the order is stable. */
|
|
89
|
+
export declare function sortBundles(bundles: SecretsBundle[], field: SortField): SecretsBundle[];
|
|
90
|
+
/** Human summary of the active filters, for the empty state. `sessions` only
|
|
91
|
+
* echoes --project/--all on a miss, which leaves you guessing which flag emptied
|
|
92
|
+
* the list; naming every active axis is the difference between "nothing matched"
|
|
93
|
+
* and knowing what to relax. */
|
|
94
|
+
export declare function describeFilter(f: SecretsListFilter): string;
|