@phnx-labs/agents-cli 1.20.49 → 1.20.51
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 +31 -0
- package/README.md +3 -0
- package/dist/commands/browser-picker.js +1 -18
- package/dist/commands/cloud.js +1 -25
- package/dist/commands/computer.d.ts +1 -0
- package/dist/commands/computer.js +129 -8
- package/dist/commands/doctor.js +133 -2
- package/dist/commands/exec.js +49 -6
- package/dist/commands/factory.js +1 -4
- package/dist/commands/inspect.js +1 -11
- package/dist/commands/mcp.js +2 -6
- package/dist/commands/message.js +1 -4
- package/dist/commands/profiles.js +1 -18
- package/dist/commands/repo.js +33 -14
- package/dist/commands/resource-view.d.ts +1 -0
- package/dist/commands/resource-view.js +5 -17
- package/dist/commands/secrets.d.ts +1 -0
- package/dist/commands/secrets.js +1 -28
- package/dist/commands/sessions-picker.js +1 -18
- package/dist/commands/sessions.js +6 -8
- package/dist/commands/teams-picker.js +1 -32
- package/dist/commands/teams.js +218 -97
- package/dist/commands/tmux.js +1 -3
- package/dist/commands/view.js +1 -9
- package/dist/commands/worktree.js +1 -4
- package/dist/lib/agents.d.ts +0 -4
- package/dist/lib/agents.js +20 -33
- package/dist/lib/auto-dispatch-linear.d.ts +18 -0
- package/dist/lib/auto-dispatch-linear.js +107 -0
- package/dist/lib/auto-dispatch-provider.d.ts +10 -0
- package/dist/lib/auto-dispatch-provider.js +25 -0
- package/dist/lib/auto-dispatch.d.ts +87 -0
- package/dist/lib/auto-dispatch.js +142 -0
- package/dist/lib/browser/cdp.js +11 -2
- package/dist/lib/browser/drivers/ssh.d.ts +28 -10
- package/dist/lib/browser/drivers/ssh.js +57 -18
- package/dist/lib/browser/refs.js +1 -5
- package/dist/lib/cli-resources.d.ts +0 -2
- package/dist/lib/cli-resources.js +30 -13
- package/dist/lib/cloud/rush.d.ts +0 -24
- package/dist/lib/cloud/rush.js +0 -31
- package/dist/lib/crabbox/cli.js +4 -1
- package/dist/lib/crabbox/lease.js +29 -1
- package/dist/lib/daemon.js +41 -0
- package/dist/lib/exec.js +43 -18
- package/dist/lib/format.d.ts +38 -0
- package/dist/lib/format.js +108 -0
- package/dist/lib/git.d.ts +21 -0
- package/dist/lib/git.js +92 -0
- package/dist/lib/hooks/cache.d.ts +9 -2
- package/dist/lib/hooks/cache.js +220 -8
- package/dist/lib/hooks.js +17 -8
- package/dist/lib/hosts/passthrough.js +30 -1
- package/dist/lib/hosts/progress.d.ts +31 -0
- package/dist/lib/hosts/progress.js +35 -0
- package/dist/lib/hosts/remote-cmd.d.ts +1 -1
- package/dist/lib/hosts/remote-cmd.js +13 -3
- package/dist/lib/platform/exec.d.ts +4 -1
- package/dist/lib/platform/exec.js +8 -2
- package/dist/lib/resources.d.ts +0 -8
- package/dist/lib/resources.js +0 -10
- package/dist/lib/runner.js +10 -2
- 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/session/active.d.ts +11 -1
- package/dist/lib/session/active.js +3 -0
- package/dist/lib/session/db.d.ts +1 -4
- package/dist/lib/session/db.js +20 -25
- package/dist/lib/session/discover.d.ts +2 -2
- package/dist/lib/session/discover.js +61 -48
- package/dist/lib/session/parse.js +35 -34
- package/dist/lib/session/render.d.ts +7 -3
- package/dist/lib/session/render.js +15 -9
- package/dist/lib/session/state.d.ts +55 -0
- package/dist/lib/session/state.js +87 -10
- package/dist/lib/session/types.d.ts +9 -0
- package/dist/lib/shims.d.ts +25 -2
- package/dist/lib/shims.js +73 -8
- package/dist/lib/ssh-tunnel.d.ts +33 -2
- package/dist/lib/ssh-tunnel.js +94 -7
- package/dist/lib/staleness/types.d.ts +0 -1
- package/dist/lib/teams/agents.d.ts +108 -1
- package/dist/lib/teams/agents.js +511 -11
- package/dist/lib/teams/api.d.ts +7 -1
- package/dist/lib/teams/api.js +5 -2
- package/dist/lib/teams/registry.d.ts +17 -0
- package/dist/lib/teams/registry.js +2 -0
- package/dist/lib/teams/remoteWorktree.d.ts +57 -0
- package/dist/lib/teams/remoteWorktree.js +213 -0
- package/dist/lib/teams/scheduler.d.ts +29 -0
- package/dist/lib/teams/scheduler.js +78 -0
- package/dist/lib/teams/supervisor.js +7 -0
- package/dist/lib/types.d.ts +14 -1
- package/dist/lib/versions.d.ts +7 -26
- package/dist/lib/versions.js +44 -146
- package/dist/lib/warn-unpushed.d.ts +40 -0
- package/dist/lib/warn-unpushed.js +128 -0
- package/package.json +3 -1
- package/dist/lib/resources/index.d.ts +0 -53
- package/dist/lib/resources/index.js +0 -76
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared terminal-formatting helpers.
|
|
3
|
+
*
|
|
4
|
+
* These small utilities were previously copy-pasted across ~20 command and lib
|
|
5
|
+
* files, and had drifted into behavior differences (truncation ellipsis `...`
|
|
6
|
+
* vs `…` vs `.`; `relTime` long "5 minutes ago" vs short "5m ago"; a
|
|
7
|
+
* `visibleWidth` regex missing its `\x1b` escape). This module is the single
|
|
8
|
+
* canonical home — every consumer imports from here.
|
|
9
|
+
*/
|
|
10
|
+
import chalk from 'chalk';
|
|
11
|
+
import { readSync } from 'node:fs';
|
|
12
|
+
/** Print `msg` in red to stderr and exit the process with `code`. */
|
|
13
|
+
export function die(msg, code = 1) {
|
|
14
|
+
console.error(chalk.red(msg));
|
|
15
|
+
process.exit(code);
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Truncate `s` to at most `max` characters, appending a single-char ellipsis
|
|
19
|
+
* (`…`) when shortened. Character-count based (not ANSI/width aware — use
|
|
20
|
+
* `truncateToWidth` from `session/width.ts` for colored strings).
|
|
21
|
+
*/
|
|
22
|
+
export function truncate(s, max) {
|
|
23
|
+
return s.length <= max ? s : s.slice(0, max - 1) + '…';
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Format an ISO timestamp as a compact relative age: "just now", "5m ago",
|
|
27
|
+
* "3h ago", "2d ago". The canonical short form — the long "5 minutes ago"
|
|
28
|
+
* variant that once lived in `cloud.ts` is deliberately dropped. (For the
|
|
29
|
+
* session-list long form with calendar fallback, see
|
|
30
|
+
* `formatRelativeTime` in `session/relative-time.ts`.)
|
|
31
|
+
*/
|
|
32
|
+
export function relTime(iso) {
|
|
33
|
+
const secs = Math.floor((Date.now() - new Date(iso).getTime()) / 1000);
|
|
34
|
+
if (secs < 10)
|
|
35
|
+
return 'just now';
|
|
36
|
+
if (secs < 60)
|
|
37
|
+
return `${secs}s ago`;
|
|
38
|
+
if (secs < 3600)
|
|
39
|
+
return `${Math.floor(secs / 60)}m ago`;
|
|
40
|
+
if (secs < 86400)
|
|
41
|
+
return `${Math.floor(secs / 3600)}h ago`;
|
|
42
|
+
return `${Math.floor(secs / 86400)}d ago`;
|
|
43
|
+
}
|
|
44
|
+
/** Format a millisecond duration as "45s", "3m", "2h 5m", "1d 3h". */
|
|
45
|
+
export function humanDuration(ms) {
|
|
46
|
+
const s = Math.floor(ms / 1000);
|
|
47
|
+
if (s < 60)
|
|
48
|
+
return `${s}s`;
|
|
49
|
+
const m = Math.floor(s / 60);
|
|
50
|
+
if (m < 60)
|
|
51
|
+
return `${m}m`;
|
|
52
|
+
const h = Math.floor(m / 60);
|
|
53
|
+
const mm = m % 60;
|
|
54
|
+
if (h < 24)
|
|
55
|
+
return mm ? `${h}h ${mm}m` : `${h}h`;
|
|
56
|
+
const d = Math.floor(h / 24);
|
|
57
|
+
const hh = h % 24;
|
|
58
|
+
return hh ? `${d}d ${hh}h` : `${d}d`;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Visible column width of `s`, ignoring ANSI SGR color codes (e.g. chalk
|
|
62
|
+
* wrappers). Matches the full CSI sequence including the `\x1b` escape.
|
|
63
|
+
*/
|
|
64
|
+
export function visibleWidth(s) {
|
|
65
|
+
// eslint-disable-next-line no-control-regex
|
|
66
|
+
return s.replace(/\x1b\[[0-9;]*m/g, '').length;
|
|
67
|
+
}
|
|
68
|
+
/** Pad `s` with trailing spaces to a target character width. */
|
|
69
|
+
export function padRight(s, width) {
|
|
70
|
+
return s.length >= width ? s : s + ' '.repeat(width - s.length);
|
|
71
|
+
}
|
|
72
|
+
/** Pad `s` with trailing spaces to a target *visible* width (ANSI-aware). */
|
|
73
|
+
export function padVisible(s, width) {
|
|
74
|
+
const w = visibleWidth(s);
|
|
75
|
+
return w >= width ? s : s + ' '.repeat(width - w);
|
|
76
|
+
}
|
|
77
|
+
/** True when `--json` was passed or stdout is not a TTY. */
|
|
78
|
+
export function isJsonMode(opts) {
|
|
79
|
+
return Boolean(opts.json) || !process.stdout.isTTY;
|
|
80
|
+
}
|
|
81
|
+
/** Read all of stdin synchronously and return it UTF-8 decoded and trimmed. */
|
|
82
|
+
export function readStdinSync() {
|
|
83
|
+
const chunks = [];
|
|
84
|
+
const buf = Buffer.alloc(65536);
|
|
85
|
+
while (true) {
|
|
86
|
+
let bytesRead;
|
|
87
|
+
try {
|
|
88
|
+
bytesRead = readSync(0, buf, 0, buf.length, null);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
if (bytesRead === 0)
|
|
94
|
+
break;
|
|
95
|
+
chunks.push(Buffer.from(buf.subarray(0, bytesRead)));
|
|
96
|
+
}
|
|
97
|
+
return Buffer.concat(chunks).toString('utf-8').trim();
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Wrap `text` in an OSC 8 hyperlink to `filePath` (as a `file://` URL) when
|
|
101
|
+
* stdout is a TTY; otherwise return `text` unchanged.
|
|
102
|
+
*/
|
|
103
|
+
export function termLink(text, filePath) {
|
|
104
|
+
if (!filePath || !process.stdout.isTTY)
|
|
105
|
+
return text;
|
|
106
|
+
const url = `file://${filePath}`;
|
|
107
|
+
return `\x1b]8;;${url}\x1b\\${text}\x1b]8;;\x1b\\`;
|
|
108
|
+
}
|
package/dist/lib/git.d.ts
CHANGED
|
@@ -104,6 +104,27 @@ export declare function cloneIntoExisting(source: string, targetDir: string): Pr
|
|
|
104
104
|
commit: string;
|
|
105
105
|
error?: string;
|
|
106
106
|
}>;
|
|
107
|
+
/**
|
|
108
|
+
* Git-back an EXISTING, populated directory from a remote — clone it in place
|
|
109
|
+
* without deleting the local files. Turns a plain `~/.agents` folder (which setup
|
|
110
|
+
* creates as a bare `mkdirSync` and never git-clones — see state.ts ensureAgentsDir)
|
|
111
|
+
* into a real clone of the user's config remote, so `agents repo pull/push` and
|
|
112
|
+
* `agents sync` work on a fresh or Windows machine that never got the manual clone.
|
|
113
|
+
*
|
|
114
|
+
* Unlike cloneIntoExisting (which blindly `checkout .`s over local files), this
|
|
115
|
+
* BACKS UP every tracked file whose local copy differs from the remote — into a
|
|
116
|
+
* sibling `<dir>.pre-adopt-backup/` OUTSIDE the repo so it can't be re-committed —
|
|
117
|
+
* before overwriting it. So a box with local edits to agents.yaml/hooks/rules
|
|
118
|
+
* doesn't silently lose them. Untracked runtime state (.cache/.history/.system,
|
|
119
|
+
* all gitignored) is never touched because `checkout .` only restores tracked paths.
|
|
120
|
+
*/
|
|
121
|
+
export declare function adoptRepo(source: string, targetDir: string): Promise<{
|
|
122
|
+
success: boolean;
|
|
123
|
+
commit: string;
|
|
124
|
+
backupDir?: string;
|
|
125
|
+
backedUp: string[];
|
|
126
|
+
error?: string;
|
|
127
|
+
}>;
|
|
107
128
|
/**
|
|
108
129
|
* Check if the repo's origin points to the system repo.
|
|
109
130
|
*/
|
package/dist/lib/git.js
CHANGED
|
@@ -462,6 +462,98 @@ export async function cloneIntoExisting(source, targetDir) {
|
|
|
462
462
|
return { success: false, commit: '', error: err.message };
|
|
463
463
|
}
|
|
464
464
|
}
|
|
465
|
+
/**
|
|
466
|
+
* Git-back an EXISTING, populated directory from a remote — clone it in place
|
|
467
|
+
* without deleting the local files. Turns a plain `~/.agents` folder (which setup
|
|
468
|
+
* creates as a bare `mkdirSync` and never git-clones — see state.ts ensureAgentsDir)
|
|
469
|
+
* into a real clone of the user's config remote, so `agents repo pull/push` and
|
|
470
|
+
* `agents sync` work on a fresh or Windows machine that never got the manual clone.
|
|
471
|
+
*
|
|
472
|
+
* Unlike cloneIntoExisting (which blindly `checkout .`s over local files), this
|
|
473
|
+
* BACKS UP every tracked file whose local copy differs from the remote — into a
|
|
474
|
+
* sibling `<dir>.pre-adopt-backup/` OUTSIDE the repo so it can't be re-committed —
|
|
475
|
+
* before overwriting it. So a box with local edits to agents.yaml/hooks/rules
|
|
476
|
+
* doesn't silently lose them. Untracked runtime state (.cache/.history/.system,
|
|
477
|
+
* all gitignored) is never touched because `checkout .` only restores tracked paths.
|
|
478
|
+
*/
|
|
479
|
+
export async function adoptRepo(source, targetDir) {
|
|
480
|
+
const trimmed = source.trim();
|
|
481
|
+
if (fs.existsSync(path.join(targetDir, '.git'))) {
|
|
482
|
+
return { success: false, commit: '', backedUp: [], error: 'Already a git repo — nothing to adopt' };
|
|
483
|
+
}
|
|
484
|
+
// Preserve the user's transport. `parseSource` THROWS for `ssh://` and any
|
|
485
|
+
// non-github `git@host:` URL, and rewrites `git@github.com:x` → https (breaking
|
|
486
|
+
// SSH-key-only auth — the common config-repo setup — so a private clone hangs on
|
|
487
|
+
// a credential prompt). So for an SSH URL, clone it AS-IS and never call
|
|
488
|
+
// parseSource; for everything else, normalize + reject local via parseSource —
|
|
489
|
+
// inside the try, so a malformed URL returns a graceful error, not a stack trace.
|
|
490
|
+
const isSsh = trimmed.startsWith('git@') || trimmed.startsWith('ssh://');
|
|
491
|
+
const tempDir = path.join(targetDir, '.git-adopt-temp');
|
|
492
|
+
try {
|
|
493
|
+
let cloneUrl;
|
|
494
|
+
let ref;
|
|
495
|
+
if (isSsh) {
|
|
496
|
+
cloneUrl = trimmed; // SSH stays SSH; clone the remote's default HEAD.
|
|
497
|
+
}
|
|
498
|
+
else {
|
|
499
|
+
const parsed = parseSource(source);
|
|
500
|
+
if (parsed.type === 'local') {
|
|
501
|
+
return { success: false, commit: '', backedUp: [], error: 'Cannot adopt from a local source' };
|
|
502
|
+
}
|
|
503
|
+
cloneUrl = parsed.url;
|
|
504
|
+
ref = parsed.ref;
|
|
505
|
+
}
|
|
506
|
+
assertSafeGitTransport(cloneUrl);
|
|
507
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
508
|
+
// Idempotency: clear a stale temp left by an interrupted prior run.
|
|
509
|
+
if (fs.existsSync(tempDir))
|
|
510
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
511
|
+
// Clone to temp, then move its .git in so the index == remote HEAD.
|
|
512
|
+
// Fail fast on a missing credential instead of hanging on a prompt: set
|
|
513
|
+
// GIT_TERMINAL_PROMPT=0 on the inherited env directly rather than via
|
|
514
|
+
// simple-git's `.env()`, which validates and rejects command-like vars the
|
|
515
|
+
// harness may set (GIT_EDITOR, PAGER, …) — the child inherits process.env,
|
|
516
|
+
// and non-interactive git is what we always want in the CLI anyway.
|
|
517
|
+
process.env.GIT_TERMINAL_PROMPT = '0';
|
|
518
|
+
await simpleGit().clone(cloneUrl, tempDir);
|
|
519
|
+
const repoGit = simpleGit(tempDir);
|
|
520
|
+
if (ref)
|
|
521
|
+
await repoGit.checkout(ref);
|
|
522
|
+
fs.renameSync(path.join(tempDir, '.git'), path.join(targetDir, '.git'));
|
|
523
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
524
|
+
const targetGit = simpleGit(targetDir);
|
|
525
|
+
// Back up any TRACKED file whose local copy differs from the remote before the
|
|
526
|
+
// checkout clobbers it. `diff --name-only` (worktree vs the moved-in index) is
|
|
527
|
+
// exactly that set; a deleted-locally file has nothing to preserve.
|
|
528
|
+
const diff = await targetGit.diff(['--name-only']);
|
|
529
|
+
const clobbered = diff.split('\n').map((s) => s.trim()).filter(Boolean);
|
|
530
|
+
let backupDir;
|
|
531
|
+
const backedUp = [];
|
|
532
|
+
if (clobbered.length > 0) {
|
|
533
|
+
backupDir = path.join(path.dirname(targetDir), path.basename(targetDir) + '.pre-adopt-backup');
|
|
534
|
+
for (const rel of clobbered) {
|
|
535
|
+
const src = path.join(targetDir, rel);
|
|
536
|
+
if (!fs.existsSync(src))
|
|
537
|
+
continue;
|
|
538
|
+
const dst = path.join(backupDir, rel);
|
|
539
|
+
fs.mkdirSync(path.dirname(dst), { recursive: true });
|
|
540
|
+
fs.copyFileSync(src, dst);
|
|
541
|
+
backedUp.push(rel);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
// Materialize the remote's tracked files (respects .gitignore, so
|
|
545
|
+
// .cache/.history/.system stay put), overwriting the now-backed-up locals.
|
|
546
|
+
await targetGit.checkout('.');
|
|
547
|
+
installGithooksSymlinks(targetDir);
|
|
548
|
+
const log = await targetGit.log({ maxCount: 1 });
|
|
549
|
+
return { success: true, commit: log.latest?.hash.slice(0, 8) || 'unknown', backupDir, backedUp };
|
|
550
|
+
}
|
|
551
|
+
catch (err) {
|
|
552
|
+
if (fs.existsSync(tempDir))
|
|
553
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
554
|
+
return { success: false, commit: '', backedUp: [], error: err.message };
|
|
555
|
+
}
|
|
556
|
+
}
|
|
465
557
|
/**
|
|
466
558
|
* Check if the repo's origin points to the system repo.
|
|
467
559
|
*/
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { HookCache, HookCacheConfig } from '../types.js';
|
|
1
|
+
import type { HookCache, HookCacheConfig, HookMatches } from '../types.js';
|
|
2
2
|
/**
|
|
3
3
|
* Parse a `cache:` value from hooks.yaml into the canonical config form.
|
|
4
4
|
* Accepts the shorthand string ("5m", "30s-bg") or the full object form.
|
|
@@ -30,11 +30,18 @@ export interface HookShimPaths {
|
|
|
30
30
|
/**
|
|
31
31
|
* Generate (or refresh) the shim script for a hook. Idempotent — only writes
|
|
32
32
|
* when the content differs from what's on disk. Returns the absolute shim path.
|
|
33
|
+
*
|
|
34
|
+
* A shim is generated when the hook opts into caching (`cache`) and/or declares
|
|
35
|
+
* `matches:` predicates. When `matches` is present the shim gates execution on
|
|
36
|
+
* those predicates before running the underlying script (see `renderShim`);
|
|
37
|
+
* when `cache` is absent the shim is a thin pass-through wrapper that only
|
|
38
|
+
* applies the gate and forwards stdin/stdout unchanged.
|
|
33
39
|
*/
|
|
34
40
|
export declare function generateHookShim(args: {
|
|
35
41
|
name: string;
|
|
36
42
|
scriptPath: string;
|
|
37
|
-
cache
|
|
43
|
+
cache?: HookCacheConfig | null;
|
|
44
|
+
matches?: HookMatches;
|
|
38
45
|
paths?: HookShimPaths;
|
|
39
46
|
}): string;
|
|
40
47
|
/**
|
package/dist/lib/hooks/cache.js
CHANGED
|
@@ -105,13 +105,19 @@ export function getHookShimPath(name) {
|
|
|
105
105
|
/**
|
|
106
106
|
* Generate (or refresh) the shim script for a hook. Idempotent — only writes
|
|
107
107
|
* when the content differs from what's on disk. Returns the absolute shim path.
|
|
108
|
+
*
|
|
109
|
+
* A shim is generated when the hook opts into caching (`cache`) and/or declares
|
|
110
|
+
* `matches:` predicates. When `matches` is present the shim gates execution on
|
|
111
|
+
* those predicates before running the underlying script (see `renderShim`);
|
|
112
|
+
* when `cache` is absent the shim is a thin pass-through wrapper that only
|
|
113
|
+
* applies the gate and forwards stdin/stdout unchanged.
|
|
108
114
|
*/
|
|
109
115
|
export function generateHookShim(args) {
|
|
110
116
|
const shimsDir = args.paths?.shimsDir ?? getHookShimsDir();
|
|
111
117
|
const cacheDir = args.paths?.cacheDir ?? getHookCacheDir();
|
|
112
118
|
const logsDir = args.paths?.logsDir ?? getLogsDir();
|
|
113
119
|
const shimPath = resolveContainedHookShimPath(shimsDir, args.name);
|
|
114
|
-
const content = renderShim(args.name, args.scriptPath, args.cache, { cacheDir, logsDir });
|
|
120
|
+
const content = renderShim(args.name, args.scriptPath, args.cache ?? null, args.matches, { cacheDir, logsDir });
|
|
115
121
|
fs.mkdirSync(shimsDir, { recursive: true });
|
|
116
122
|
let existing = null;
|
|
117
123
|
if (fs.existsSync(shimPath)) {
|
|
@@ -132,24 +138,204 @@ export function generateHookShim(args) {
|
|
|
132
138
|
}
|
|
133
139
|
return shimPath;
|
|
134
140
|
}
|
|
141
|
+
/**
|
|
142
|
+
* The matches: gate, as a self-contained Python program run once per fire.
|
|
143
|
+
*
|
|
144
|
+
* Reads the hook's `matches:` block from $MATCHES_JSON and the event JSON from
|
|
145
|
+
* stdin, then prints `FIRE` or `SKIP`. A faithful port of `shouldFire()` in
|
|
146
|
+
* src/lib/hooks/match.ts — all declared predicates AND together, an empty block
|
|
147
|
+
* always fires, and the same ReDoS guard (`isSafeHookRegex`) rejects unsafe
|
|
148
|
+
* regexes to `SKIP`. Kept in double-quotes/apostrophe-free so it survives being
|
|
149
|
+
* embedded in a single-quoted `python -c '...'` argument in the shim. Behavioural
|
|
150
|
+
* parity with shouldFire() is pinned by a conformance test (match-parity.test.ts).
|
|
151
|
+
*
|
|
152
|
+
* On any exception it does NOT print SKIP — the shim treats a missing/garbled
|
|
153
|
+
* verdict as FIRE (fail-open), so a broken gate never silently disables a hook.
|
|
154
|
+
*/
|
|
155
|
+
const GATE_PY = `import json, os, re, subprocess, sys
|
|
156
|
+
|
|
157
|
+
def arr(v):
|
|
158
|
+
if v is None:
|
|
159
|
+
return []
|
|
160
|
+
return v if isinstance(v, list) else [v]
|
|
161
|
+
|
|
162
|
+
def max_group_depth(src):
|
|
163
|
+
depth = 0
|
|
164
|
+
mx = 0
|
|
165
|
+
escaped = False
|
|
166
|
+
in_class = False
|
|
167
|
+
for ch in src:
|
|
168
|
+
if escaped:
|
|
169
|
+
escaped = False
|
|
170
|
+
continue
|
|
171
|
+
if ch == chr(92):
|
|
172
|
+
escaped = True
|
|
173
|
+
continue
|
|
174
|
+
if ch == "[":
|
|
175
|
+
in_class = True
|
|
176
|
+
continue
|
|
177
|
+
if ch == "]":
|
|
178
|
+
in_class = False
|
|
179
|
+
continue
|
|
180
|
+
if in_class:
|
|
181
|
+
continue
|
|
182
|
+
if ch == "(":
|
|
183
|
+
depth += 1
|
|
184
|
+
if depth > mx:
|
|
185
|
+
mx = depth
|
|
186
|
+
elif ch == ")" and depth > 0:
|
|
187
|
+
depth -= 1
|
|
188
|
+
return mx
|
|
189
|
+
|
|
190
|
+
_NESTED = re.compile(r"\\((?:\\?:)?[^)]*[*+][?+*{,\\d}]*[^)]*\\)\\s*(?:[+*]|\\{\\d*,?\\d*\\})")
|
|
191
|
+
|
|
192
|
+
def is_safe(src):
|
|
193
|
+
if len(src) > 200:
|
|
194
|
+
return False
|
|
195
|
+
if max_group_depth(src) > 3:
|
|
196
|
+
return False
|
|
197
|
+
if _NESTED.search(src):
|
|
198
|
+
return False
|
|
199
|
+
return True
|
|
200
|
+
|
|
201
|
+
def compile_rx(src):
|
|
202
|
+
if not is_safe(src):
|
|
203
|
+
return None
|
|
204
|
+
try:
|
|
205
|
+
return re.compile(src)
|
|
206
|
+
except re.error:
|
|
207
|
+
return None
|
|
208
|
+
|
|
209
|
+
def find_root(start):
|
|
210
|
+
d = os.path.abspath(start)
|
|
211
|
+
while True:
|
|
212
|
+
if os.path.exists(os.path.join(d, ".git")):
|
|
213
|
+
return d
|
|
214
|
+
parent = os.path.dirname(d)
|
|
215
|
+
if parent == d:
|
|
216
|
+
return None
|
|
217
|
+
d = parent
|
|
218
|
+
|
|
219
|
+
def git_dirty(cwd):
|
|
220
|
+
try:
|
|
221
|
+
out = subprocess.run(
|
|
222
|
+
["git", "status", "--porcelain"],
|
|
223
|
+
cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
|
|
224
|
+
)
|
|
225
|
+
return len(out.stdout.decode().strip()) > 0
|
|
226
|
+
except Exception:
|
|
227
|
+
return False
|
|
228
|
+
|
|
229
|
+
def should_fire():
|
|
230
|
+
m = json.loads(os.environ.get("MATCHES_JSON") or "{}")
|
|
231
|
+
if not m:
|
|
232
|
+
return True
|
|
233
|
+
try:
|
|
234
|
+
inp = json.load(sys.stdin)
|
|
235
|
+
except Exception:
|
|
236
|
+
inp = {}
|
|
237
|
+
cwd = inp.get("cwd") or os.getcwd()
|
|
238
|
+
|
|
239
|
+
v = m.get("prompt_contains")
|
|
240
|
+
if v is not None:
|
|
241
|
+
if v not in (inp.get("prompt") or ""):
|
|
242
|
+
return False
|
|
243
|
+
|
|
244
|
+
v = m.get("prompt_matches")
|
|
245
|
+
if v is not None:
|
|
246
|
+
rx = compile_rx(v)
|
|
247
|
+
if rx is None or not rx.search(inp.get("prompt") or ""):
|
|
248
|
+
return False
|
|
249
|
+
|
|
250
|
+
v = m.get("tool_name")
|
|
251
|
+
if v is not None:
|
|
252
|
+
allowed = arr(v)
|
|
253
|
+
if allowed:
|
|
254
|
+
tn = inp.get("tool_name")
|
|
255
|
+
if not tn or tn not in allowed:
|
|
256
|
+
return False
|
|
257
|
+
|
|
258
|
+
v = m.get("tool_args_match")
|
|
259
|
+
if v is not None:
|
|
260
|
+
ta = inp.get("tool_args")
|
|
261
|
+
ser = ta if isinstance(ta, str) else json.dumps(ta if ta is not None else "", separators=(",", ":"))
|
|
262
|
+
rx = compile_rx(v)
|
|
263
|
+
if rx is None or not rx.search(ser):
|
|
264
|
+
return False
|
|
265
|
+
|
|
266
|
+
v = m.get("cwd_includes")
|
|
267
|
+
if v is not None:
|
|
268
|
+
needles = arr(v)
|
|
269
|
+
if needles and not any(n in cwd for n in needles):
|
|
270
|
+
return False
|
|
271
|
+
|
|
272
|
+
v = m.get("project_has")
|
|
273
|
+
if v is not None:
|
|
274
|
+
root = find_root(cwd)
|
|
275
|
+
if not root or not os.path.exists(os.path.join(root, v)):
|
|
276
|
+
return False
|
|
277
|
+
|
|
278
|
+
v = m.get("git_dirty")
|
|
279
|
+
if v is not None:
|
|
280
|
+
if bool(v) != git_dirty(cwd):
|
|
281
|
+
return False
|
|
282
|
+
|
|
283
|
+
return True
|
|
284
|
+
|
|
285
|
+
print("FIRE" if should_fire() else "SKIP")
|
|
286
|
+
`;
|
|
287
|
+
/**
|
|
288
|
+
* Gate-only pass-through tail: no caching, just run the underlying script with
|
|
289
|
+
* stdin forwarded and stdout/exit code propagated, plus one timing log line.
|
|
290
|
+
* Used when a hook declares \`matches:\` but no \`cache:\`.
|
|
291
|
+
*/
|
|
292
|
+
const PASSTHROUGH_TAIL = `now_ns() { "$PY" -c 'import time; print(int(time.time()*1e9))'; }
|
|
293
|
+
START_NS=$(now_ns)
|
|
294
|
+
EXIT=0
|
|
295
|
+
if printf '%s' "$STDIN_PAYLOAD" | "$SOURCE"; then
|
|
296
|
+
EXIT=0
|
|
297
|
+
else
|
|
298
|
+
EXIT=$?
|
|
299
|
+
fi
|
|
300
|
+
END_NS=$(now_ns)
|
|
301
|
+
MS=$(( (END_NS - START_NS) / 1000000 ))
|
|
302
|
+
TS=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
|
303
|
+
LOG_FILE="$LOGS_DIR/events-$(date -u +%Y-%m-%d).jsonl"
|
|
304
|
+
printf '{"ts":"%s","event":"hook.fire","hook":"%s","ms":%d,"cache":"%s","exit":%d}\\n' \\
|
|
305
|
+
"$TS" "$HOOK_NAME" "$MS" "none" "$EXIT" >>"$LOG_FILE" 2>/dev/null || true
|
|
306
|
+
|
|
307
|
+
exit "$EXIT"`;
|
|
135
308
|
/**
|
|
136
309
|
* Render the bash shim. Bash 3.2-compatible (macOS default). Uses Python for
|
|
137
310
|
* hashing + monotonic-ish nanosecond timing + portable mtime, resolved at
|
|
138
311
|
* runtime (python3, then python) so a Windows Microsoft Store `python3` alias
|
|
139
312
|
* stub — which exits non-zero without running — doesn't silently break caching.
|
|
313
|
+
*
|
|
314
|
+
* When `matches` is set, an early gate block evaluates the `matches:` predicates
|
|
315
|
+
* against the event JSON on stdin and exits 0 without running the script when
|
|
316
|
+
* they don't hold — this is the runtime enforcement of the documented `matches:`
|
|
317
|
+
* gating (mirrors `shouldFire()` in match.ts). When `cache` is null the shim is
|
|
318
|
+
* a gate-only pass-through: it forwards stdin to the script and its stdout back,
|
|
319
|
+
* with no cache read/write.
|
|
140
320
|
*/
|
|
141
|
-
function renderShim(name, scriptPath, cache, paths) {
|
|
142
|
-
const ttl = typeof cache.ttl === 'number' ? cache.ttl : (parseDuration(cache.ttl) ?? 0);
|
|
143
|
-
const key = cache
|
|
144
|
-
const prefetch = cache
|
|
321
|
+
function renderShim(name, scriptPath, cache, matches, paths) {
|
|
322
|
+
const ttl = cache ? (typeof cache.ttl === 'number' ? cache.ttl : (parseDuration(cache.ttl) ?? 0)) : 0;
|
|
323
|
+
const key = cache?.key ?? 'global';
|
|
324
|
+
const prefetch = cache?.prefetch ?? 'none';
|
|
145
325
|
const { cacheDir, logsDir } = paths;
|
|
326
|
+
const hasMatches = matches != null && Object.keys(matches).length > 0;
|
|
327
|
+
const matchesJson = hasMatches ? JSON.stringify(matches) : '';
|
|
146
328
|
// sh-escape: wrap in single quotes, escape any embedded single quotes.
|
|
147
329
|
const q = (s) => `'${s.replace(/'/g, `'\\''`)}'`;
|
|
330
|
+
const cacheHeader = cache
|
|
331
|
+
? `# Cache: key=${key} ttl=${ttl}s prefetch=${prefetch}`
|
|
332
|
+
: `# Cache: none (gate-only pass-through)`;
|
|
333
|
+
const matchesHeader = hasMatches ? `\n# Matches: ${matchesJson}` : '';
|
|
148
334
|
return `#!/usr/bin/env bash
|
|
149
335
|
# GENERATED by agents-cli. Do not edit — re-run \`agents hooks sync\` to refresh.
|
|
150
336
|
# Hook: ${name}
|
|
151
337
|
# Source: ${scriptPath}
|
|
152
|
-
|
|
338
|
+
${cacheHeader}${matchesHeader}
|
|
153
339
|
set -u
|
|
154
340
|
|
|
155
341
|
HOOK_NAME=${q(name)}
|
|
@@ -159,6 +345,7 @@ LOGS_DIR=${q(logsDir)}
|
|
|
159
345
|
TTL=${ttl}
|
|
160
346
|
PREFETCH=${q(prefetch)}
|
|
161
347
|
KEY_MODE=${q(key)}
|
|
348
|
+
MATCHES_JSON=${q(matchesJson)}
|
|
162
349
|
|
|
163
350
|
mkdir -p "$CACHE_DIR" "$LOGS_DIR"
|
|
164
351
|
|
|
@@ -178,7 +365,33 @@ done
|
|
|
178
365
|
# Read stdin once (Claude/Codex/Gemini pass JSON on stdin to every hook).
|
|
179
366
|
STDIN_PAYLOAD="$(cat || true)"
|
|
180
367
|
|
|
181
|
-
#
|
|
368
|
+
# --- matches: gate (issue #744 / RUSH-1506) -------------------------------
|
|
369
|
+
# Enforce the hook's declared \`matches:\` predicates at fire time. Mirrors
|
|
370
|
+
# shouldFire() in src/lib/hooks/match.ts: all declared predicates AND together;
|
|
371
|
+
# an empty/absent block always fires. When the predicates don't hold we exit 0
|
|
372
|
+
# WITHOUT running the script (a skipped hook is not an error). Fail-open: any
|
|
373
|
+
# gate-eval error runs the script, so a broken predicate can never silently
|
|
374
|
+
# disable a safety hook (e.g. git-guard).
|
|
375
|
+
if [ -n "$MATCHES_JSON" ]; then
|
|
376
|
+
_GATE="$(printf '%s' "$STDIN_PAYLOAD" | MATCHES_JSON="$MATCHES_JSON" "$PY" -c ${q(GATE_PY)} 2>/dev/null || printf FIRE)"
|
|
377
|
+
[ -z "$_GATE" ] && _GATE=FIRE
|
|
378
|
+
if [ "$_GATE" = SKIP ]; then
|
|
379
|
+
_TS=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
|
380
|
+
_LOG_FILE="$LOGS_DIR/events-$(date -u +%Y-%m-%d).jsonl"
|
|
381
|
+
printf '{"ts":"%s","event":"hook.fire","hook":"%s","ms":0,"cache":"skip","exit":0}\\n' \\
|
|
382
|
+
"$_TS" "$HOOK_NAME" >>"$_LOG_FILE" 2>/dev/null || true
|
|
383
|
+
exit 0
|
|
384
|
+
fi
|
|
385
|
+
fi
|
|
386
|
+
${cache ? CACHE_TAIL : PASSTHROUGH_TAIL}
|
|
387
|
+
`;
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* Cache tail: the full cache lookup / stale-while-revalidate / timing machinery.
|
|
391
|
+
* Emitted only when the hook opts into \`cache:\`. (When only \`matches:\` is set,
|
|
392
|
+
* PASSTHROUGH_TAIL runs instead — no cache read/write.)
|
|
393
|
+
*/
|
|
394
|
+
const CACHE_TAIL = `# Portable sha1 — \`shasum\` is Perl, missing on minimal Linux images;
|
|
182
395
|
# \`sha1sum\` is coreutils, missing on macOS. Truncate to 12 hex chars.
|
|
183
396
|
sha1_12() { "$PY" -c 'import hashlib,sys; print(hashlib.sha1(sys.stdin.read().encode()).hexdigest()[:12])'; }
|
|
184
397
|
|
|
@@ -267,7 +480,6 @@ printf '{"ts":"%s","event":"hook.fire","hook":"%s","ms":%d,"cache":"%s","exit":%
|
|
|
267
480
|
|
|
268
481
|
exit "$EXIT"
|
|
269
482
|
`;
|
|
270
|
-
}
|
|
271
483
|
/**
|
|
272
484
|
* Remove a hook's shim. Called by the registrar's garbage collection when a
|
|
273
485
|
* hook is renamed/deleted or has its `cache:` field removed.
|
package/dist/lib/hooks.js
CHANGED
|
@@ -153,10 +153,12 @@ import { getHookShimsDir } from './state.js';
|
|
|
153
153
|
/**
|
|
154
154
|
* Resolve the command path to register for a hook.
|
|
155
155
|
*
|
|
156
|
-
* Returns either the raw script path (
|
|
157
|
-
* the path to a generated
|
|
158
|
-
* side effect when `cache:` is configured
|
|
159
|
-
*
|
|
156
|
+
* Returns either the raw script path (neither `cache:` nor `matches:` set,
|
|
157
|
+
* legacy behavior) or the path to a generated wrapper shim. The shim is written
|
|
158
|
+
* as a side effect when `cache:` and/or `matches:` is configured — it enforces
|
|
159
|
+
* the `matches:` gate at fire time and layers the caching/timing machinery when
|
|
160
|
+
* `cache:` is set. The agent-native settings file gets the same shape either
|
|
161
|
+
* way — just a different command path.
|
|
160
162
|
*/
|
|
161
163
|
function resolveHookCommand(name, hookDef, resolveScript) {
|
|
162
164
|
const scriptPath = resolveScript(hookDef.script);
|
|
@@ -165,13 +167,20 @@ function resolveHookCommand(name, hookDef, resolveScript) {
|
|
|
165
167
|
if (!isValidHookShimName(name))
|
|
166
168
|
return null;
|
|
167
169
|
const cache = parseCacheConfig(hookDef.cache);
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
170
|
+
const matches = hookDef.matches;
|
|
171
|
+
const hasMatches = matches != null && Object.keys(matches).length > 0;
|
|
172
|
+
if (!cache && !hasMatches) {
|
|
173
|
+
// No caching and no matches: gate opted in — make sure a previously
|
|
174
|
+
// generated shim from an earlier `cache:`/`matches:` config is gone so the
|
|
175
|
+
// JSONL doesn't keep claiming hits.
|
|
171
176
|
removeHookShim(name);
|
|
172
177
|
return toPortableCommand(scriptPath);
|
|
173
178
|
}
|
|
174
|
-
|
|
179
|
+
// A shim is generated when the hook opts into caching and/or declares
|
|
180
|
+
// `matches:` predicates. The shim enforces the `matches:` gate at fire time
|
|
181
|
+
// (skipping the script when predicates don't hold) and, when `cache:` is set,
|
|
182
|
+
// layers the cache/timing machinery on top.
|
|
183
|
+
return toPortableCommand(generateHookShim({ name, scriptPath, cache, matches }));
|
|
175
184
|
}
|
|
176
185
|
/**
|
|
177
186
|
* Extensions that are NEVER hooks — docs, configuration, plain data. A file
|
|
@@ -91,6 +91,20 @@ export async function maybeRunOnHost(command, allArgs) {
|
|
|
91
91
|
const spec = REMOTE_PASSTHROUGH[command];
|
|
92
92
|
if (!spec)
|
|
93
93
|
return false;
|
|
94
|
+
// Placement, not routing: `teams add`/`teams create` read `--device`/`--devices`
|
|
95
|
+
// (and `--host`/`--hosts`) as WHERE to place a teammate / the team pool — the
|
|
96
|
+
// command itself always runs locally on the orchestrator. Bail before the
|
|
97
|
+
// generic teams routing below so those flags reach the local action. Every
|
|
98
|
+
// other teams subcommand (`status`/`logs`/`stop`/…) keeps `--host` routing.
|
|
99
|
+
// Find the subcommand = the first non-flag token AFTER `teams` (robust to any
|
|
100
|
+
// leading global flags), then bail for the add/create aliases.
|
|
101
|
+
if (command === 'teams') {
|
|
102
|
+
const teamsIdx = allArgs.indexOf('teams');
|
|
103
|
+
const sub = teamsIdx >= 0 ? allArgs.slice(teamsIdx + 1).find((a) => !a.startsWith('-')) : undefined;
|
|
104
|
+
if (sub === 'add' || sub === 'a' || sub === 'create' || sub === 'c' || sub === 'new') {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
94
108
|
// `--device` is a first-class alias of `--host` (mirrors `agents run`); the
|
|
95
109
|
// device registry is the source of truth for machine identity. Reject a
|
|
96
110
|
// conflicting pair rather than silently preferring one — same rule as run.
|
|
@@ -101,6 +115,11 @@ export async function maybeRunOnHost(command, allArgs) {
|
|
|
101
115
|
process.exitCode = 1;
|
|
102
116
|
return true;
|
|
103
117
|
}
|
|
118
|
+
// `--devices` / `--hosts` fan out to every registered device locally; don't
|
|
119
|
+
// let a per-host passthrough turn it into a cascading remote fan-out.
|
|
120
|
+
const fleetFlag = allArgs.includes('--devices') || allArgs.includes('--hosts');
|
|
121
|
+
if (fleetFlag)
|
|
122
|
+
return false;
|
|
104
123
|
const hostName = hostFlag ?? deviceFlag;
|
|
105
124
|
if (!hostName)
|
|
106
125
|
return false;
|
|
@@ -142,7 +161,17 @@ export async function maybeRunOnHost(command, allArgs) {
|
|
|
142
161
|
}
|
|
143
162
|
return true;
|
|
144
163
|
}
|
|
145
|
-
|
|
164
|
+
// Doctor commands probe the agent CLIs; remote POSIX login shells often don't
|
|
165
|
+
// have the agents shims on PATH, which produces false "not installed" negatives.
|
|
166
|
+
// Bootstrap PATH with the canonical shim locations before the remote command.
|
|
167
|
+
// Windows is skipped: PowerShell usually has the shim dir via the install
|
|
168
|
+
// profile, and single-quoted env values would not expand $HOME/$PATH.
|
|
169
|
+
const isDoctorCommand = command === 'doctor' || (command === 'teams' && forwarded[1] === 'doctor');
|
|
170
|
+
const remoteOs = resolveRemoteOsSync(host.name);
|
|
171
|
+
const env = isDoctorCommand && !/^win/i.test((remoteOs ?? '').trim())
|
|
172
|
+
? { PATH: '$HOME/.agents/.cache/shims:$HOME/.local/bin:$PATH' }
|
|
173
|
+
: undefined;
|
|
174
|
+
const remoteCmd = buildRemoteAgentsInvocation(forwarded, remoteCwd, remoteOs, env);
|
|
146
175
|
const code = sshStream(target, remoteCmd, { tty: interactive, multiplex: true });
|
|
147
176
|
if (code === 255) {
|
|
148
177
|
console.error(chalk.red(`${host.name}: unreachable over SSH (asleep, offline, or host key changed?).`) +
|
|
@@ -13,6 +13,37 @@
|
|
|
13
13
|
* toward `maxPollMs` while the job is idle, so a quiet long-running follow no
|
|
14
14
|
* longer spawns thousands of ssh processes per hour on the laptop.
|
|
15
15
|
*/
|
|
16
|
+
/**
|
|
17
|
+
* Cap for the LOCAL mirror of a distributed teammate's remote log. `followHostTask`
|
|
18
|
+
* appends remote bytes into the local mirror forever; a team can spin 10+ chatty
|
|
19
|
+
* remote teammates, so the orchestrator must keep a bounded window (the full log
|
|
20
|
+
* always lives on the host). The teams remote path (agents.ts readNewEvents) writes
|
|
21
|
+
* its own append into each teammate's `stdout.log`, then truncates that file to its
|
|
22
|
+
* trailing `REMOTE_MIRROR_MAX_BYTES` — the parser has already consumed the bytes
|
|
23
|
+
* (status/digest updated via lastReadPos), so trailing-tail history is dead weight.
|
|
24
|
+
*/
|
|
25
|
+
export declare const REMOTE_MIRROR_MAX_BYTES: number;
|
|
26
|
+
/**
|
|
27
|
+
* Pull the new bytes of a remote log since `offset` in ONE ssh round-trip.
|
|
28
|
+
*
|
|
29
|
+
* The teams remote-teammate monitor calls this each poll to advance its offset-tail
|
|
30
|
+
* cursor into the host's log, mirroring only the delta into the local `stdout.log`
|
|
31
|
+
* the stream-json parser consumes. Byte-exact (raw Buffer, no UTF-8 decode) so a
|
|
32
|
+
* multibyte character split at the `tail -c` boundary neither drifts the offset nor
|
|
33
|
+
* renders as U+FFFD — the same discipline `fetchProgress` uses. `bytes.length` is
|
|
34
|
+
* the exact wire count; `newOffset` is `offset + bytes.length`.
|
|
35
|
+
*
|
|
36
|
+
* Returns null on a transient ssh failure (the caller retries next poll without
|
|
37
|
+
* advancing). `remoteLog` is a $HOME-prefixed path with a safe basename; it's
|
|
38
|
+
* shell-quoted defensively even so.
|
|
39
|
+
*/
|
|
40
|
+
export declare function pullRemoteLogDelta(target: string, opts: {
|
|
41
|
+
remoteLog: string;
|
|
42
|
+
offset: number;
|
|
43
|
+
}): {
|
|
44
|
+
bytes: Buffer;
|
|
45
|
+
newOffset: number;
|
|
46
|
+
} | null;
|
|
16
47
|
export interface FollowOptions {
|
|
17
48
|
remoteLog: string;
|
|
18
49
|
remoteExit: string;
|