@phnx-labs/agents-cli 1.20.91 → 1.20.93
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/events.js +91 -1
- package/dist/commands/exec.d.ts +14 -0
- package/dist/commands/exec.js +144 -14
- package/dist/commands/projects.d.ts +22 -0
- package/dist/commands/projects.js +539 -0
- package/dist/commands/secrets.d.ts +17 -0
- package/dist/commands/secrets.js +198 -7
- package/dist/commands/send.d.ts +14 -12
- package/dist/commands/send.js +105 -35
- package/dist/commands/sessions-picker.d.ts +15 -0
- package/dist/commands/sessions-picker.js +37 -12
- 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/commands/sync.js +9 -3
- package/dist/commands/view.js +4 -0
- package/dist/index.js +18 -1
- package/dist/lib/activity.d.ts +77 -12
- package/dist/lib/activity.js +424 -74
- package/dist/lib/beta.d.ts +1 -1
- package/dist/lib/beta.js +1 -1
- package/dist/lib/channels/send.d.ts +83 -0
- package/dist/lib/channels/send.js +112 -0
- package/dist/lib/devices/registry.d.ts +14 -0
- package/dist/lib/devices/registry.js +37 -0
- package/dist/lib/events-ingest.d.ts +46 -0
- package/dist/lib/events-ingest.js +182 -0
- package/dist/lib/events.d.ts +15 -3
- package/dist/lib/events.js +55 -3
- package/dist/lib/feed-post.js +8 -2
- package/dist/lib/hosts/remote-cmd.js +4 -0
- package/dist/lib/linear-project-counts.d.ts +62 -0
- package/dist/lib/linear-project-counts.js +122 -0
- package/dist/lib/linear-projects.d.ts +50 -0
- package/dist/lib/linear-projects.js +114 -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/menubar/notify-desktop.d.ts +17 -2
- package/dist/lib/menubar/notify-desktop.js +8 -2
- package/dist/lib/project-key.d.ts +44 -0
- package/dist/lib/project-key.js +79 -0
- package/dist/lib/project-probe.d.ts +75 -0
- package/dist/lib/project-probe.js +160 -0
- package/dist/lib/project-resources.d.ts +8 -0
- package/dist/lib/project-resources.js +31 -3
- package/dist/lib/project-root.js +16 -0
- package/dist/lib/project-status.d.ts +100 -0
- package/dist/lib/project-status.js +182 -0
- package/dist/lib/projects.d.ts +144 -0
- package/dist/lib/projects.js +313 -0
- package/dist/lib/remote-agents-json.d.ts +9 -0
- package/dist/lib/remote-agents-json.js +11 -5
- package/dist/lib/routine-notify.d.ts +11 -0
- package/dist/lib/routine-notify.js +22 -0
- package/dist/lib/run-notify.js +3 -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/audit.d.ts +1 -1
- package/dist/lib/secrets/audit.js +53 -10
- package/dist/lib/secrets/list-filter.d.ts +20 -5
- package/dist/lib/secrets/list-filter.js +22 -6
- package/dist/lib/secrets/usage-db.d.ts +106 -0
- package/dist/lib/secrets/usage-db.js +236 -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 +6 -0
- package/dist/lib/session/digest.js +19 -0
- package/dist/lib/session/relative-time.d.ts +23 -0
- package/dist/lib/session/relative-time.js +60 -8
- package/dist/lib/session/remote-active.d.ts +5 -1
- package/dist/lib/session/remote-active.js +4 -1
- package/dist/lib/session/remote-list.js +5 -2
- package/dist/lib/session/render.d.ts +2 -9
- package/dist/lib/session/render.js +25 -56
- package/dist/lib/sqlite.js +28 -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 +1 -0
- package/dist/lib/startup/command-registry.js +2 -0
- package/dist/lib/state.d.ts +14 -0
- package/dist/lib/state.js +19 -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 +6 -5
- package/dist/lib/versions.d.ts +6 -0
- package/dist/lib/versions.js +6 -4
- package/package.json +2 -1
|
@@ -0,0 +1,144 @@
|
|
|
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
|
+
/** A git repo bound to a project, with an optional monorepo subpath. */
|
|
22
|
+
export interface ProjectRepo {
|
|
23
|
+
/** GitHub slug `owner/repo`. */
|
|
24
|
+
slug: string;
|
|
25
|
+
/** Optional path within the repo an agent working this project cares about. */
|
|
26
|
+
subpath?: string;
|
|
27
|
+
/**
|
|
28
|
+
* Optional home-relative local checkout of this repo. The def's `root` only
|
|
29
|
+
* knows the primary repo on disk; `path` opts an additional repo into
|
|
30
|
+
* workspace probing (`projects status --fleet`).
|
|
31
|
+
*/
|
|
32
|
+
path?: string;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* A described context anchor: a subdirectory plus what it is. Agents starting on
|
|
36
|
+
* the project read `purpose` to know where to look — an indexed starting point,
|
|
37
|
+
* not just a path. This is the richer form of the single monorepo-focus dir.
|
|
38
|
+
*/
|
|
39
|
+
export interface ProjectContext {
|
|
40
|
+
/** Path relative to the project root (e.g. `apps/web`). */
|
|
41
|
+
path: string;
|
|
42
|
+
/** One line on how this subtree relates to the project. */
|
|
43
|
+
purpose: string;
|
|
44
|
+
}
|
|
45
|
+
/** An external context source hung off the project (surfaced in `projects show`). */
|
|
46
|
+
export interface ProjectIntegration {
|
|
47
|
+
/** e.g. `gdrive`, `notion`, `figma`, `url`. */
|
|
48
|
+
kind: string;
|
|
49
|
+
url: string;
|
|
50
|
+
label?: string;
|
|
51
|
+
}
|
|
52
|
+
/** The parsed `~/.agents/projects/<name>.yaml`. */
|
|
53
|
+
export interface ProjectDef {
|
|
54
|
+
/** Stable id; matches the filename; what `--project` takes. */
|
|
55
|
+
name: string;
|
|
56
|
+
description?: string;
|
|
57
|
+
/** Repo / monorepo root, home-relative for portability. */
|
|
58
|
+
root?: string;
|
|
59
|
+
/** Where an agent's cwd lands. Defaults to `root` when unset. */
|
|
60
|
+
defaultPath?: string;
|
|
61
|
+
/** Primary GitHub slug (`owner/repo`) — for PR / CI / status roll-up. */
|
|
62
|
+
repo?: string;
|
|
63
|
+
/** All bound repos, each with an optional monorepo subpath. */
|
|
64
|
+
repos?: ProjectRepo[];
|
|
65
|
+
/** Described starting points inside the project. */
|
|
66
|
+
contexts?: ProjectContext[];
|
|
67
|
+
/** External context sources (Drive, docs, …). */
|
|
68
|
+
integrations?: ProjectIntegration[];
|
|
69
|
+
/** Linear project link — reuses the existing GraphQL path. */
|
|
70
|
+
linear?: {
|
|
71
|
+
projectId?: string;
|
|
72
|
+
url?: string;
|
|
73
|
+
};
|
|
74
|
+
/** Free-form doc links surfaced in `projects show`. */
|
|
75
|
+
docs?: string[];
|
|
76
|
+
}
|
|
77
|
+
/** A project name safe to use as a filename: no separators, `..`, or leading dot. */
|
|
78
|
+
export declare function isSafeProjectName(name: string): boolean;
|
|
79
|
+
/** Absolute path to a project's YAML definition. Throws on an unsafe name. */
|
|
80
|
+
export declare function projectDefPath(name: string): string;
|
|
81
|
+
/**
|
|
82
|
+
* Validate a raw parsed object into a `ProjectDef`, throwing an actionable error
|
|
83
|
+
* on the first problem. A malformed document or identity (bad/mismatched name)
|
|
84
|
+
* throws; malformed entries inside the optional lists (`repos`/`contexts`/
|
|
85
|
+
* `integrations`) are dropped so one bad row can't sink an otherwise good def.
|
|
86
|
+
*/
|
|
87
|
+
export declare function validateProjectDef(raw: unknown, sourceName?: string): ProjectDef;
|
|
88
|
+
/**
|
|
89
|
+
* Load a single project definition by name. Returns undefined when the file is
|
|
90
|
+
* absent (the common "not a defined project, fall back to convention" case) but
|
|
91
|
+
* throws when a file EXISTS and is malformed — a broken definition is loud.
|
|
92
|
+
*/
|
|
93
|
+
export declare function loadProjectDef(name: string): ProjectDef | undefined;
|
|
94
|
+
/**
|
|
95
|
+
* List every defined project, sorted by name. Skips (does not throw on) a
|
|
96
|
+
* malformed file so one bad definition can't break `projects list`; the loader
|
|
97
|
+
* for a single named project stays strict.
|
|
98
|
+
*/
|
|
99
|
+
export declare function listProjectDefs(): ProjectDef[];
|
|
100
|
+
/**
|
|
101
|
+
* Persist a project definition, normalizing `root`/`defaultPath` to home-relative
|
|
102
|
+
* so it stays portable across machines. Creates the projects dir on first write.
|
|
103
|
+
*/
|
|
104
|
+
export declare function writeProjectDef(def: ProjectDef): string;
|
|
105
|
+
/** Delete a project definition. Returns true if a file was removed. Never touches the repo. */
|
|
106
|
+
export declare function removeProjectDef(name: string): boolean;
|
|
107
|
+
/**
|
|
108
|
+
* The cwd an agent lands in for a defined project: `defaultPath` when set, else
|
|
109
|
+
* `root`. Home-relative when `forRemote` (the remote shell expands `~`), else
|
|
110
|
+
* expanded against the local home. Returns undefined when neither is set.
|
|
111
|
+
*/
|
|
112
|
+
export declare function projectBasePath(def: ProjectDef, forRemote: boolean): string | undefined;
|
|
113
|
+
/**
|
|
114
|
+
* Which defined project a session belongs to, derived from its working
|
|
115
|
+
* directory. A session whose `cwd` sits inside a project's repo root (or a
|
|
116
|
+
* worktree under it) is a member; the LONGEST matching root wins so a nested
|
|
117
|
+
* project beats its parent. Returns undefined when no definition contains the
|
|
118
|
+
* path.
|
|
119
|
+
*
|
|
120
|
+
* The comparison is against the LOCAL home: roots and the cwd are both expanded
|
|
121
|
+
* with `expandLocalHome` and resolved, so this matches sessions whose cwd shares
|
|
122
|
+
* this machine's home layout. A session recorded on a different-home machine
|
|
123
|
+
* (`/Users/x/…` vs `/home/x/…`) will not match until the fleet-wide,
|
|
124
|
+
* home-relative variant lands (see the deferred item in docs/11-projects.md).
|
|
125
|
+
*/
|
|
126
|
+
export declare function projectNameForCwd(cwd: string | undefined, defs: ProjectDef[]): string | undefined;
|
|
127
|
+
/**
|
|
128
|
+
* The canonical project label for a cwd, for every surface that buckets work by
|
|
129
|
+
* project (the activity timeline, feed posts, the sessions overview): the
|
|
130
|
+
* DEFINED project whose root contains the cwd (longest root wins, so a
|
|
131
|
+
* multi-repo project reads as one bucket), else the repository-level key from
|
|
132
|
+
* {@link resolveProjectKey}. `defs` comes from {@link listProjectDefs}, which is
|
|
133
|
+
* fail-open — with no definitions this degrades to exactly today's behavior.
|
|
134
|
+
*/
|
|
135
|
+
export declare function resolveProjectNameForCwd(cwd: string | undefined | null, defs: ProjectDef[]): string | undefined;
|
|
136
|
+
/**
|
|
137
|
+
* Resolve a defined project's ref to a working directory, mirroring
|
|
138
|
+
* `buildProjectPath`'s `forRemote` contract (a home-relative `~/…` for the
|
|
139
|
+
* remote shell to expand, an absolute local path otherwise). A `@worktree`
|
|
140
|
+
* lands under the repo ROOT's `.agents/worktrees/`, not the `defaultPath`
|
|
141
|
+
* subdir — worktrees are per-repo, not per-focus. Returns undefined when the
|
|
142
|
+
* definition carries no `root`/`defaultPath` (caller falls back to convention).
|
|
143
|
+
*/
|
|
144
|
+
export declare function resolveDefinedProjectPath(def: ProjectDef, worktree: string | undefined, forRemote: boolean): string | undefined;
|
|
@@ -0,0 +1,313 @@
|
|
|
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
|
+
// A malformed `path` sinks the whole entry, like any other malformed
|
|
90
|
+
// list row — a half-valid repo must not probe a surprising location.
|
|
91
|
+
if (rr.path !== undefined && typeof rr.path !== 'string')
|
|
92
|
+
return [];
|
|
93
|
+
const repo = { slug: rr.slug };
|
|
94
|
+
if (typeof rr.subpath === 'string')
|
|
95
|
+
repo.subpath = rr.subpath;
|
|
96
|
+
if (typeof rr.path === 'string')
|
|
97
|
+
repo.path = rr.path;
|
|
98
|
+
return [repo];
|
|
99
|
+
}
|
|
100
|
+
return [];
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
if (Array.isArray(o.contexts)) {
|
|
104
|
+
def.contexts = o.contexts.flatMap((c) => {
|
|
105
|
+
if (c &&
|
|
106
|
+
typeof c === 'object' &&
|
|
107
|
+
typeof c.path === 'string' &&
|
|
108
|
+
typeof c.purpose === 'string') {
|
|
109
|
+
const cc = c;
|
|
110
|
+
return [{ path: cc.path, purpose: cc.purpose }];
|
|
111
|
+
}
|
|
112
|
+
return [];
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
if (Array.isArray(o.integrations)) {
|
|
116
|
+
def.integrations = o.integrations.flatMap((i) => {
|
|
117
|
+
if (i &&
|
|
118
|
+
typeof i === 'object' &&
|
|
119
|
+
typeof i.kind === 'string' &&
|
|
120
|
+
typeof i.url === 'string') {
|
|
121
|
+
const ii = i;
|
|
122
|
+
const integ = { kind: ii.kind, url: ii.url };
|
|
123
|
+
if (typeof ii.label === 'string')
|
|
124
|
+
integ.label = ii.label;
|
|
125
|
+
return [integ];
|
|
126
|
+
}
|
|
127
|
+
return [];
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
if (o.linear && typeof o.linear === 'object' && !Array.isArray(o.linear)) {
|
|
131
|
+
const l = o.linear;
|
|
132
|
+
def.linear = {};
|
|
133
|
+
if (typeof l.projectId === 'string')
|
|
134
|
+
def.linear.projectId = l.projectId;
|
|
135
|
+
if (typeof l.url === 'string')
|
|
136
|
+
def.linear.url = l.url;
|
|
137
|
+
}
|
|
138
|
+
if (Array.isArray(o.docs))
|
|
139
|
+
def.docs = o.docs.filter((d) => typeof d === 'string');
|
|
140
|
+
return def;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Load a single project definition by name. Returns undefined when the file is
|
|
144
|
+
* absent (the common "not a defined project, fall back to convention" case) but
|
|
145
|
+
* throws when a file EXISTS and is malformed — a broken definition is loud.
|
|
146
|
+
*/
|
|
147
|
+
export function loadProjectDef(name) {
|
|
148
|
+
if (!isSafeProjectName(name))
|
|
149
|
+
return undefined;
|
|
150
|
+
let raw;
|
|
151
|
+
try {
|
|
152
|
+
raw = fs.readFileSync(projectDefPath(name), 'utf8');
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
return undefined; // absent — not a defined project
|
|
156
|
+
}
|
|
157
|
+
return validateProjectDef(yaml.parse(raw), name);
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* List every defined project, sorted by name. Skips (does not throw on) a
|
|
161
|
+
* malformed file so one bad definition can't break `projects list`; the loader
|
|
162
|
+
* for a single named project stays strict.
|
|
163
|
+
*/
|
|
164
|
+
export function listProjectDefs() {
|
|
165
|
+
let files;
|
|
166
|
+
try {
|
|
167
|
+
// Definitions are `<name>.yaml` (what projectDefPath/loadProjectDef read). We
|
|
168
|
+
// deliberately do NOT list `.yml` here — accepting it would then ENOENT in the
|
|
169
|
+
// loader and silently drop the project. One extension, one code path.
|
|
170
|
+
files = fs.readdirSync(getProjectsDir()).filter((f) => f.endsWith('.yaml'));
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
return [];
|
|
174
|
+
}
|
|
175
|
+
const out = [];
|
|
176
|
+
for (const f of files) {
|
|
177
|
+
const name = f.replace(/\.yaml$/, '');
|
|
178
|
+
try {
|
|
179
|
+
const def = loadProjectDef(name);
|
|
180
|
+
if (def)
|
|
181
|
+
out.push(def);
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
/* malformed — skip in the listing */
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return out.sort((a, b) => a.name.localeCompare(b.name));
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Persist a project definition, normalizing `root`/`defaultPath` to home-relative
|
|
191
|
+
* so it stays portable across machines. Creates the projects dir on first write.
|
|
192
|
+
*/
|
|
193
|
+
export function writeProjectDef(def) {
|
|
194
|
+
const validated = validateProjectDef(def, def.name);
|
|
195
|
+
const normalized = {
|
|
196
|
+
...validated,
|
|
197
|
+
root: validated.root ? toHomeRelative(expandLocalHome(validated.root)) : undefined,
|
|
198
|
+
defaultPath: validated.defaultPath
|
|
199
|
+
? toHomeRelative(expandLocalHome(validated.defaultPath))
|
|
200
|
+
: undefined,
|
|
201
|
+
repos: validated.repos?.map((r) => {
|
|
202
|
+
const repo = { ...r };
|
|
203
|
+
if (r.path)
|
|
204
|
+
repo.path = toHomeRelative(expandLocalHome(r.path));
|
|
205
|
+
return repo;
|
|
206
|
+
}),
|
|
207
|
+
};
|
|
208
|
+
// Drop undefined keys so the YAML stays clean.
|
|
209
|
+
const clean = Object.fromEntries(Object.entries(normalized).filter(([, v]) => v !== undefined));
|
|
210
|
+
const target = projectDefPath(def.name);
|
|
211
|
+
fs.mkdirSync(getProjectsDir(), { recursive: true });
|
|
212
|
+
fs.writeFileSync(target, yaml.stringify(clean), 'utf8');
|
|
213
|
+
return target;
|
|
214
|
+
}
|
|
215
|
+
/** Delete a project definition. Returns true if a file was removed. Never touches the repo. */
|
|
216
|
+
export function removeProjectDef(name) {
|
|
217
|
+
try {
|
|
218
|
+
fs.unlinkSync(projectDefPath(name));
|
|
219
|
+
return true;
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* The cwd an agent lands in for a defined project: `defaultPath` when set, else
|
|
227
|
+
* `root`. Home-relative when `forRemote` (the remote shell expands `~`), else
|
|
228
|
+
* expanded against the local home. Returns undefined when neither is set.
|
|
229
|
+
*/
|
|
230
|
+
export function projectBasePath(def, forRemote) {
|
|
231
|
+
const base = def.defaultPath ?? def.root;
|
|
232
|
+
if (!base)
|
|
233
|
+
return undefined;
|
|
234
|
+
return forRemote ? base : expandLocalHome(base);
|
|
235
|
+
}
|
|
236
|
+
function projectRootsAbs(defs) {
|
|
237
|
+
const out = [];
|
|
238
|
+
for (const def of defs) {
|
|
239
|
+
const raw = def.root ?? def.defaultPath;
|
|
240
|
+
if (!raw)
|
|
241
|
+
continue;
|
|
242
|
+
out.push({ name: def.name, abs: path.resolve(expandLocalHome(raw)) });
|
|
243
|
+
}
|
|
244
|
+
return out;
|
|
245
|
+
}
|
|
246
|
+
/** True when `child` is `parent` or nested under it (path-segment aware). */
|
|
247
|
+
function isUnder(child, parent) {
|
|
248
|
+
if (child === parent)
|
|
249
|
+
return true;
|
|
250
|
+
const withSep = parent.endsWith(path.sep) ? parent : parent + path.sep;
|
|
251
|
+
return child.startsWith(withSep);
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Which defined project a session belongs to, derived from its working
|
|
255
|
+
* directory. A session whose `cwd` sits inside a project's repo root (or a
|
|
256
|
+
* worktree under it) is a member; the LONGEST matching root wins so a nested
|
|
257
|
+
* project beats its parent. Returns undefined when no definition contains the
|
|
258
|
+
* path.
|
|
259
|
+
*
|
|
260
|
+
* The comparison is against the LOCAL home: roots and the cwd are both expanded
|
|
261
|
+
* with `expandLocalHome` and resolved, so this matches sessions whose cwd shares
|
|
262
|
+
* this machine's home layout. A session recorded on a different-home machine
|
|
263
|
+
* (`/Users/x/…` vs `/home/x/…`) will not match until the fleet-wide,
|
|
264
|
+
* home-relative variant lands (see the deferred item in docs/11-projects.md).
|
|
265
|
+
*/
|
|
266
|
+
export function projectNameForCwd(cwd, defs) {
|
|
267
|
+
if (!cwd)
|
|
268
|
+
return undefined;
|
|
269
|
+
const abs = path.resolve(expandLocalHome(cwd));
|
|
270
|
+
let best;
|
|
271
|
+
let bestLen = -1;
|
|
272
|
+
for (const { name, abs: root } of projectRootsAbs(defs)) {
|
|
273
|
+
if (isUnder(abs, root) && root.length > bestLen) {
|
|
274
|
+
best = name;
|
|
275
|
+
bestLen = root.length;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return best;
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* The canonical project label for a cwd, for every surface that buckets work by
|
|
282
|
+
* project (the activity timeline, feed posts, the sessions overview): the
|
|
283
|
+
* DEFINED project whose root contains the cwd (longest root wins, so a
|
|
284
|
+
* multi-repo project reads as one bucket), else the repository-level key from
|
|
285
|
+
* {@link resolveProjectKey}. `defs` comes from {@link listProjectDefs}, which is
|
|
286
|
+
* fail-open — with no definitions this degrades to exactly today's behavior.
|
|
287
|
+
*/
|
|
288
|
+
export function resolveProjectNameForCwd(cwd, defs) {
|
|
289
|
+
if (!cwd)
|
|
290
|
+
return undefined;
|
|
291
|
+
return projectNameForCwd(cwd, defs) ?? resolveProjectKey(cwd);
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Resolve a defined project's ref to a working directory, mirroring
|
|
295
|
+
* `buildProjectPath`'s `forRemote` contract (a home-relative `~/…` for the
|
|
296
|
+
* remote shell to expand, an absolute local path otherwise). A `@worktree`
|
|
297
|
+
* lands under the repo ROOT's `.agents/worktrees/`, not the `defaultPath`
|
|
298
|
+
* subdir — worktrees are per-repo, not per-focus. Returns undefined when the
|
|
299
|
+
* definition carries no `root`/`defaultPath` (caller falls back to convention).
|
|
300
|
+
*/
|
|
301
|
+
export function resolveDefinedProjectPath(def, worktree, forRemote) {
|
|
302
|
+
if (worktree) {
|
|
303
|
+
const rootRaw = def.root ?? def.defaultPath;
|
|
304
|
+
if (!rootRaw)
|
|
305
|
+
return undefined;
|
|
306
|
+
const wt = `${rootRaw}/.agents/worktrees/${worktree}`;
|
|
307
|
+
return forRemote ? wt : path.resolve(expandLocalHome(wt));
|
|
308
|
+
}
|
|
309
|
+
const base = projectBasePath(def, forRemote);
|
|
310
|
+
if (!base)
|
|
311
|
+
return undefined;
|
|
312
|
+
return forRemote ? base : path.resolve(base);
|
|
313
|
+
}
|
|
@@ -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
|
}
|
|
@@ -26,6 +26,17 @@ import { type DesktopNotification } from './menubar/notify-desktop.js';
|
|
|
26
26
|
type RoutineKind = 'agent' | 'workflow' | 'command';
|
|
27
27
|
/** Which flavor of routine a config/meta describes — drives the notify threshold. */
|
|
28
28
|
export declare function routineKind(r: Pick<JobConfig, 'agent' | 'workflow' | 'command'>): RoutineKind;
|
|
29
|
+
/**
|
|
30
|
+
* The harness a routine runs on, for the notification's right-hand avatar, or
|
|
31
|
+
* undefined when none owns it. A command routine is deterministic housekeeping
|
|
32
|
+
* with no agent, so it gets no avatar. An agent routine names its own harness.
|
|
33
|
+
* A workflow routine has no `agent` field (the schema omits it — routines.ts
|
|
34
|
+
* `JobConfig.agent` and the validation that rejects setting both), and it runs
|
|
35
|
+
* via `agents run <workflow>`, which delegates to claude under the hood — so its
|
|
36
|
+
* avatar is the Claude mark, matching `effectiveAgent` on the finish path
|
|
37
|
+
* (runner.ts). Start and finish banners therefore show the same avatar.
|
|
38
|
+
*/
|
|
39
|
+
export declare function routineAgent(r: Pick<JobConfig, 'agent' | 'workflow' | 'command'>): string | undefined;
|
|
29
40
|
/** "1m 20s" / "45s" / "2h 3m" from a millisecond duration, or null when unknown. */
|
|
30
41
|
export declare function formatDuration(ms: number | undefined): string | null;
|
|
31
42
|
/**
|
|
@@ -33,6 +33,24 @@ export function routineKind(r) {
|
|
|
33
33
|
return 'workflow';
|
|
34
34
|
return 'agent';
|
|
35
35
|
}
|
|
36
|
+
/**
|
|
37
|
+
* The harness a routine runs on, for the notification's right-hand avatar, or
|
|
38
|
+
* undefined when none owns it. A command routine is deterministic housekeeping
|
|
39
|
+
* with no agent, so it gets no avatar. An agent routine names its own harness.
|
|
40
|
+
* A workflow routine has no `agent` field (the schema omits it — routines.ts
|
|
41
|
+
* `JobConfig.agent` and the validation that rejects setting both), and it runs
|
|
42
|
+
* via `agents run <workflow>`, which delegates to claude under the hood — so its
|
|
43
|
+
* avatar is the Claude mark, matching `effectiveAgent` on the finish path
|
|
44
|
+
* (runner.ts). Start and finish banners therefore show the same avatar.
|
|
45
|
+
*/
|
|
46
|
+
export function routineAgent(r) {
|
|
47
|
+
const kind = routineKind(r);
|
|
48
|
+
if (kind === 'command')
|
|
49
|
+
return undefined;
|
|
50
|
+
if (kind === 'workflow')
|
|
51
|
+
return 'claude';
|
|
52
|
+
return r.agent?.trim() || undefined;
|
|
53
|
+
}
|
|
36
54
|
/** Human label for the routine body ("agent claude", "workflow deploy", "command"). */
|
|
37
55
|
function routineLabel(r) {
|
|
38
56
|
if (r.command)
|
|
@@ -90,6 +108,7 @@ export function routineStartNotification(config) {
|
|
|
90
108
|
subtitle: config.name,
|
|
91
109
|
body: `Running ${routineLabel(config)}`,
|
|
92
110
|
action: 'routines:list',
|
|
111
|
+
agent: routineAgent(config),
|
|
93
112
|
};
|
|
94
113
|
}
|
|
95
114
|
/**
|
|
@@ -108,6 +127,7 @@ export function routineStartFailedNotification(config, error) {
|
|
|
108
127
|
subtitle: config.name,
|
|
109
128
|
body: `Failed to start: ${error}`,
|
|
110
129
|
action: 'routines:list',
|
|
130
|
+
agent: routineAgent(config),
|
|
111
131
|
};
|
|
112
132
|
}
|
|
113
133
|
/**
|
|
@@ -131,6 +151,7 @@ export function routineFinishNotification(meta, opts = {}) {
|
|
|
131
151
|
subtitle: meta.jobName,
|
|
132
152
|
body: snippet ?? (dur ? `Completed in ${dur}` : 'Completed'),
|
|
133
153
|
action,
|
|
154
|
+
agent: routineAgent(meta),
|
|
134
155
|
};
|
|
135
156
|
}
|
|
136
157
|
// failed | timeout
|
|
@@ -144,6 +165,7 @@ export function routineFinishNotification(meta, opts = {}) {
|
|
|
144
165
|
subtitle: meta.jobName,
|
|
145
166
|
body: reason,
|
|
146
167
|
action,
|
|
168
|
+
agent: routineAgent(meta),
|
|
147
169
|
};
|
|
148
170
|
}
|
|
149
171
|
/** Read a finished run's report text + the best artifact to open on click. */
|
package/dist/lib/run-notify.js
CHANGED
|
@@ -34,6 +34,9 @@ export function buildRunFinishNotification(ctx, exitCode) {
|
|
|
34
34
|
const n = {
|
|
35
35
|
title: exitCode === 0 ? `${label} finished` : `${label} failed`,
|
|
36
36
|
body: shorten(ctx.prompt?.trim() || `${ctx.agent} run`),
|
|
37
|
+
// The harness that ran becomes the banner's right-hand avatar, so a finished
|
|
38
|
+
// run is identifiable at a glance even when `--name` renamed the title.
|
|
39
|
+
agent: ctx.agent,
|
|
37
40
|
};
|
|
38
41
|
if (where)
|
|
39
42
|
n.subtitle = where;
|
|
Binary file
|
|
Binary file
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type SecretAuditEvent = 'secrets.get' | 'secrets.unlocked';
|
|
1
|
+
export type SecretAuditEvent = 'secrets.get' | 'secrets.unlocked' | 'secrets.create' | 'secrets.import' | 'secrets.export' | 'secrets.view';
|
|
2
2
|
export interface SecretAuditParams {
|
|
3
3
|
/** Which audit event this is — a read (`secrets.get`) or an unlock grant. */
|
|
4
4
|
event: SecretAuditEvent;
|