@phnx-labs/agents-cli 1.20.50 → 1.20.52

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.
Files changed (124) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/dist/commands/browser-picker.js +1 -18
  3. package/dist/commands/browser.js +215 -7
  4. package/dist/commands/cloud.js +7 -25
  5. package/dist/commands/computer.d.ts +1 -0
  6. package/dist/commands/computer.js +129 -8
  7. package/dist/commands/events.d.ts +1 -1
  8. package/dist/commands/events.js +2 -3
  9. package/dist/commands/exec.js +66 -8
  10. package/dist/commands/factory.js +9 -4
  11. package/dist/commands/feed.d.ts +9 -0
  12. package/dist/commands/feed.js +69 -0
  13. package/dist/commands/inspect.js +1 -11
  14. package/dist/commands/logs.d.ts +5 -1
  15. package/dist/commands/logs.js +248 -3
  16. package/dist/commands/mcp.js +9 -6
  17. package/dist/commands/message.js +1 -4
  18. package/dist/commands/profiles.js +1 -18
  19. package/dist/commands/repo.js +33 -14
  20. package/dist/commands/resource-view.d.ts +1 -0
  21. package/dist/commands/resource-view.js +5 -17
  22. package/dist/commands/secrets.d.ts +23 -0
  23. package/dist/commands/secrets.js +174 -70
  24. package/dist/commands/sessions-picker.js +1 -18
  25. package/dist/commands/sessions.js +6 -8
  26. package/dist/commands/teams-picker.js +1 -32
  27. package/dist/commands/teams.js +5 -27
  28. package/dist/commands/tmux.js +1 -3
  29. package/dist/commands/view.js +1 -9
  30. package/dist/commands/worktree.js +1 -4
  31. package/dist/index.js +6 -2
  32. package/dist/lib/agents.d.ts +0 -4
  33. package/dist/lib/agents.js +20 -33
  34. package/dist/lib/auto-dispatch-linear.d.ts +18 -0
  35. package/dist/lib/auto-dispatch-linear.js +107 -0
  36. package/dist/lib/auto-dispatch-provider.d.ts +10 -0
  37. package/dist/lib/auto-dispatch-provider.js +25 -0
  38. package/dist/lib/auto-dispatch.d.ts +87 -0
  39. package/dist/lib/auto-dispatch.js +142 -0
  40. package/dist/lib/browser/cdp.js +11 -2
  41. package/dist/lib/browser/drivers/ssh.d.ts +28 -10
  42. package/dist/lib/browser/drivers/ssh.js +57 -18
  43. package/dist/lib/browser/login-detection.d.ts +94 -0
  44. package/dist/lib/browser/login-detection.js +274 -0
  45. package/dist/lib/browser/profiles.d.ts +17 -8
  46. package/dist/lib/browser/profiles.js +27 -8
  47. package/dist/lib/browser/refs.js +1 -5
  48. package/dist/lib/browser/secret-ref.d.ts +10 -0
  49. package/dist/lib/browser/secret-ref.js +14 -0
  50. package/dist/lib/browser/service.js +14 -12
  51. package/dist/lib/cli-resources.d.ts +0 -2
  52. package/dist/lib/cli-resources.js +30 -13
  53. package/dist/lib/cloud/rush.d.ts +15 -24
  54. package/dist/lib/cloud/rush.js +7 -32
  55. package/dist/lib/crabbox/cli.js +4 -1
  56. package/dist/lib/crabbox/lease.d.ts +6 -0
  57. package/dist/lib/crabbox/lease.js +40 -10
  58. package/dist/lib/crabbox/runtimes.d.ts +38 -1
  59. package/dist/lib/crabbox/runtimes.js +98 -5
  60. package/dist/lib/daemon.d.ts +12 -9
  61. package/dist/lib/daemon.js +73 -17
  62. package/dist/lib/events.d.ts +31 -5
  63. package/dist/lib/events.js +288 -101
  64. package/dist/lib/exec.js +32 -14
  65. package/dist/lib/feed.d.ts +56 -0
  66. package/dist/lib/feed.js +251 -0
  67. package/dist/lib/format.d.ts +38 -0
  68. package/dist/lib/format.js +108 -0
  69. package/dist/lib/git.d.ts +21 -0
  70. package/dist/lib/git.js +92 -0
  71. package/dist/lib/hooks/cache.d.ts +9 -2
  72. package/dist/lib/hooks/cache.js +220 -8
  73. package/dist/lib/hooks.js +24 -10
  74. package/dist/lib/hosts/passthrough.js +1 -0
  75. package/dist/lib/platform/exec.d.ts +4 -1
  76. package/dist/lib/platform/exec.js +8 -2
  77. package/dist/lib/resources.d.ts +0 -8
  78. package/dist/lib/resources.js +0 -10
  79. package/dist/lib/rotate.js +2 -0
  80. package/dist/lib/runner.js +10 -2
  81. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  82. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  83. package/dist/lib/secrets/agent.d.ts +21 -0
  84. package/dist/lib/secrets/agent.js +63 -1
  85. package/dist/lib/secrets/bundles.d.ts +33 -1
  86. package/dist/lib/secrets/bundles.js +38 -8
  87. package/dist/lib/secrets/icloud-import.d.ts +70 -0
  88. package/dist/lib/secrets/icloud-import.js +173 -0
  89. package/dist/lib/secrets/index.d.ts +36 -0
  90. package/dist/lib/secrets/index.js +99 -9
  91. package/dist/lib/secrets/remote.js +1 -1
  92. package/dist/lib/secrets/sync.js +1 -1
  93. package/dist/lib/session/active.d.ts +11 -1
  94. package/dist/lib/session/active.js +3 -0
  95. package/dist/lib/session/db.d.ts +1 -4
  96. package/dist/lib/session/db.js +20 -25
  97. package/dist/lib/session/discover.d.ts +2 -2
  98. package/dist/lib/session/discover.js +60 -48
  99. package/dist/lib/session/parse.js +35 -34
  100. package/dist/lib/session/render.d.ts +7 -3
  101. package/dist/lib/session/render.js +15 -9
  102. package/dist/lib/session/state.d.ts +55 -0
  103. package/dist/lib/session/state.js +100 -11
  104. package/dist/lib/session/types.d.ts +9 -0
  105. package/dist/lib/shims.d.ts +9 -3
  106. package/dist/lib/shims.js +44 -8
  107. package/dist/lib/ssh-tunnel.d.ts +33 -2
  108. package/dist/lib/ssh-tunnel.js +94 -7
  109. package/dist/lib/staleness/types.d.ts +0 -1
  110. package/dist/lib/startup/command-registry.d.ts +1 -0
  111. package/dist/lib/startup/command-registry.js +2 -0
  112. package/dist/lib/state.d.ts +2 -0
  113. package/dist/lib/state.js +25 -8
  114. package/dist/lib/teams/agents.js +6 -3
  115. package/dist/lib/types.d.ts +24 -1
  116. package/dist/lib/versions.d.ts +0 -26
  117. package/dist/lib/versions.js +2 -145
  118. package/dist/lib/warn-unpushed.d.ts +40 -0
  119. package/dist/lib/warn-unpushed.js +128 -0
  120. package/dist/lib/whats-new.d.ts +5 -3
  121. package/dist/lib/whats-new.js +25 -5
  122. package/package.json +3 -1
  123. package/dist/lib/resources/index.d.ts +0 -53
  124. package/dist/lib/resources/index.js +0 -76
@@ -0,0 +1,56 @@
1
+ export interface BlockOption {
2
+ label: string;
3
+ description?: string;
4
+ }
5
+ export interface BlockQuestion {
6
+ text: string;
7
+ header?: string;
8
+ options?: BlockOption[];
9
+ multiSelect?: boolean;
10
+ }
11
+ export interface OpenBlock {
12
+ blockId: string;
13
+ sessionId: string;
14
+ mailboxId: string;
15
+ host: string;
16
+ runtime: string;
17
+ ts: string;
18
+ questions: BlockQuestion[];
19
+ ticket?: string;
20
+ pr?: string;
21
+ }
22
+ /**
23
+ * Stable block id for a session. One block per session -- a new question
24
+ * replaces the previous one (the agent can only ask one question at a time).
25
+ */
26
+ export declare function blockIdForSession(sessionId: string): string;
27
+ /** Atomic write a block record to the feed store. */
28
+ export declare function publishBlock(block: OpenBlock, root?: string): void;
29
+ /** Read all block records. Returns them sorted by stable block filename. */
30
+ export declare function listBlocks(root?: string): OpenBlock[];
31
+ /** Remove a block record. Returns true if the file was deleted. */
32
+ export declare function removeBlock(blockId: string, root?: string): boolean;
33
+ /**
34
+ * The feed-publish PreToolUse hook script (Python, mirroring 09-mailbox-inject.py).
35
+ * Embedded so it ships with the compiled CLI and can be installed to the
36
+ * CLI-writable user hooks dir without a separate file in the npm tarball.
37
+ */
38
+ export declare const FEED_PUBLISH_HOOK_SCRIPT = "#!/usr/bin/env python3\n\"\"\"PreToolUse hook: publish an open-block record when the agent calls\nAskUserQuestion, so `agents feed` can aggregate pending decisions.\n\nOutbound counterpart to the inbound mailbox-inject hook. Fires only on\nAskUserQuestion (matcher-gated in agents.yaml). Writes one block per session\nto ~/.agents/.history/feed/. A new question replaces the previous block.\n\nSub-agent gate: when the PreToolUse payload carries `agent_type`, this is a\nTask/Agent subagent -- skip. Only the top-level agent publishes. Verified on\nClaude Code 2.1.170 (2026-07).\n\nFail-open: ANY error is swallowed so a feed hiccup never blocks a tool call.\n\"\"\"\nimport os\nimport sys\nimport json\nimport re\nimport socket\nimport tempfile\nfrom datetime import datetime, timezone\n\n\ndef main():\n raw = sys.stdin.read()\n try:\n payload = json.loads(raw) if raw.strip() else {}\n except Exception:\n return\n\n # Sub-agent gate.\n if payload.get(\"agent_type\"):\n return\n\n tool_input = payload.get(\"tool_input\", {})\n questions = tool_input.get(\"questions\", [])\n if not questions:\n return\n\n session_id = payload.get(\"session_id\", \"\")\n if not session_id:\n return\n\n normalized_questions = []\n for q in questions:\n if not isinstance(q, dict):\n continue\n question = {\n \"text\": q.get(\"question\", q.get(\"header\", \"\")),\n \"header\": q.get(\"header\"),\n \"multiSelect\": q.get(\"multiSelect\", False),\n }\n raw_opts = q.get(\"options\", [])\n if raw_opts:\n question[\"options\"] = [\n {\"label\": o.get(\"label\", \"\"), \"description\": o.get(\"description\")}\n for o in raw_opts\n if isinstance(o, dict)\n ]\n normalized_questions.append(question)\n if not normalized_questions:\n return\n\n # Identity from env (set by agents-cli at spawn).\n mailbox_id = os.path.basename(\n os.environ.get(\"AGENTS_MAILBOX_DIR\", \"\").rstrip(\"/\")\n ) or session_id\n\n hostname = os.environ.get(\"AGENTS_SYNC_MACHINE_ID\") or socket.gethostname()\n host = hostname.split(\".\")[0].strip().lower()\n host = re.sub(r\"[^a-z0-9_-]\", \"-\", host) or \"unknown\"\n\n runtime = os.environ.get(\"AGENTS_RUNTIME\", \"headless\")\n\n safe_session_id = re.sub(r\"[^A-Za-z0-9._-]\", \"-\", session_id)\n block_id = f\"block-{safe_session_id}\"\n block = {\n \"blockId\": block_id,\n \"sessionId\": session_id,\n \"mailboxId\": mailbox_id,\n \"host\": host,\n \"runtime\": runtime,\n \"ts\": datetime.now(timezone.utc).isoformat(),\n \"questions\": normalized_questions,\n }\n\n # Python's expanduser() ignores HOME on Windows, while agents-cli honors a\n # HOME override on every platform. Use the same anchor so hooks and the CLI\n # always read/write one feed store (including temp-home and sandbox runs).\n home = os.environ.get(\"HOME\") or os.path.expanduser(\"~\")\n feed_dir = os.path.join(home, \".agents\", \".history\", \"feed\")\n os.makedirs(feed_dir, exist_ok=True)\n\n target = os.path.join(feed_dir, f\"{block_id}.json\")\n fd, tmp = tempfile.mkstemp(dir=feed_dir, suffix=\".tmp\")\n try:\n with os.fdopen(fd, \"w\") as f:\n json.dump(block, f, indent=2)\n os.rename(tmp, target)\n except Exception:\n try:\n os.unlink(tmp)\n except Exception:\n pass\n\n\nif __name__ == \"__main__\":\n try:\n main()\n except Exception:\n pass # fail open\n";
39
+ /** Manifest entry for the feed-publish hook, matching the ManifestHook shape. */
40
+ export declare const FEED_PUBLISH_HOOK_MANIFEST: {
41
+ name: string;
42
+ events: string[];
43
+ matcher: string;
44
+ script: string;
45
+ timeout: number;
46
+ };
47
+ /**
48
+ * Install the feed-publish hook script into the user hooks dir and add its
49
+ * manifest entry to the user agents.yaml. The system repo is an auto-pulled,
50
+ * read-only mirror, so runtime-managed hooks must never write there.
51
+ * Idempotent -- skips if the script is already present and up to date.
52
+ */
53
+ export declare function ensureFeedPublishHook(userAgentsDir?: string): {
54
+ installed: boolean;
55
+ error?: string;
56
+ };
@@ -0,0 +1,251 @@
1
+ /**
2
+ * Feed store -- structured block records published by agents waiting on user
3
+ * input (AskUserQuestion). The outbound counterpart to the inbound mailbox:
4
+ * the mailbox delivers messages TO agents; the feed surfaces decisions agents
5
+ * need FROM the user.
6
+ *
7
+ * Layout: <feedDir>/<blockId>.json
8
+ * Each file is one open block -- a question the agent asked. One block per
9
+ * session: a new AskUserQuestion in the same session replaces the previous
10
+ * block (an agent can only ask one question at a time). Removed when the
11
+ * session advances past the block.
12
+ *
13
+ * A block carries enough identity (sessionId, mailboxId, host, runtime) for
14
+ * `agents feed` to aggregate across hosts and for `agents message` to route
15
+ * a reply back to the right agent.
16
+ */
17
+ import * as fs from 'fs';
18
+ import * as path from 'path';
19
+ import * as yaml from 'yaml';
20
+ import { getFeedDir, getUserAgentsDir } from './state.js';
21
+ /**
22
+ * Stable block id for a session. One block per session -- a new question
23
+ * replaces the previous one (the agent can only ask one question at a time).
24
+ */
25
+ export function blockIdForSession(sessionId) {
26
+ const safeSessionId = sessionId.replace(/[^A-Za-z0-9._-]/g, '-');
27
+ return `block-${safeSessionId}`;
28
+ }
29
+ function blockPath(root, blockId) {
30
+ if (!/^[A-Za-z0-9._-]+$/.test(blockId)) {
31
+ throw new Error(`Invalid feed block id: ${blockId}`);
32
+ }
33
+ return path.join(root, `${blockId}.json`);
34
+ }
35
+ /** Atomic write a block record to the feed store. */
36
+ export function publishBlock(block, root) {
37
+ const dir = root ?? getFeedDir();
38
+ fs.mkdirSync(dir, { recursive: true });
39
+ const target = blockPath(dir, block.blockId);
40
+ const tmp = `${target}.${process.pid}.tmp`;
41
+ fs.writeFileSync(tmp, JSON.stringify(block, null, 2), 'utf-8');
42
+ fs.renameSync(tmp, target);
43
+ }
44
+ /** Read all block records. Returns them sorted by stable block filename. */
45
+ export function listBlocks(root) {
46
+ const dir = root ?? getFeedDir();
47
+ let names;
48
+ try {
49
+ names = fs.readdirSync(dir);
50
+ }
51
+ catch {
52
+ return [];
53
+ }
54
+ const blocks = [];
55
+ for (const name of names.filter(n => n.endsWith('.json')).sort()) {
56
+ try {
57
+ const raw = fs.readFileSync(path.join(dir, name), 'utf-8');
58
+ const parsed = JSON.parse(raw);
59
+ if (parsed.blockId && parsed.sessionId && parsed.questions?.length) {
60
+ blocks.push(parsed);
61
+ }
62
+ }
63
+ catch {
64
+ // skip corrupt / partial files
65
+ }
66
+ }
67
+ return blocks;
68
+ }
69
+ /** Remove a block record. Returns true if the file was deleted. */
70
+ export function removeBlock(blockId, root) {
71
+ const dir = root ?? getFeedDir();
72
+ try {
73
+ fs.unlinkSync(blockPath(dir, blockId));
74
+ return true;
75
+ }
76
+ catch {
77
+ return false;
78
+ }
79
+ }
80
+ // ---------------------------------------------------------------------------
81
+ // Hook installation
82
+ // ---------------------------------------------------------------------------
83
+ /**
84
+ * The feed-publish PreToolUse hook script (Python, mirroring 09-mailbox-inject.py).
85
+ * Embedded so it ships with the compiled CLI and can be installed to the
86
+ * CLI-writable user hooks dir without a separate file in the npm tarball.
87
+ */
88
+ export const FEED_PUBLISH_HOOK_SCRIPT = `#!/usr/bin/env python3
89
+ """PreToolUse hook: publish an open-block record when the agent calls
90
+ AskUserQuestion, so \`agents feed\` can aggregate pending decisions.
91
+
92
+ Outbound counterpart to the inbound mailbox-inject hook. Fires only on
93
+ AskUserQuestion (matcher-gated in agents.yaml). Writes one block per session
94
+ to ~/.agents/.history/feed/. A new question replaces the previous block.
95
+
96
+ Sub-agent gate: when the PreToolUse payload carries \`agent_type\`, this is a
97
+ Task/Agent subagent -- skip. Only the top-level agent publishes. Verified on
98
+ Claude Code 2.1.170 (2026-07).
99
+
100
+ Fail-open: ANY error is swallowed so a feed hiccup never blocks a tool call.
101
+ """
102
+ import os
103
+ import sys
104
+ import json
105
+ import re
106
+ import socket
107
+ import tempfile
108
+ from datetime import datetime, timezone
109
+
110
+
111
+ def main():
112
+ raw = sys.stdin.read()
113
+ try:
114
+ payload = json.loads(raw) if raw.strip() else {}
115
+ except Exception:
116
+ return
117
+
118
+ # Sub-agent gate.
119
+ if payload.get("agent_type"):
120
+ return
121
+
122
+ tool_input = payload.get("tool_input", {})
123
+ questions = tool_input.get("questions", [])
124
+ if not questions:
125
+ return
126
+
127
+ session_id = payload.get("session_id", "")
128
+ if not session_id:
129
+ return
130
+
131
+ normalized_questions = []
132
+ for q in questions:
133
+ if not isinstance(q, dict):
134
+ continue
135
+ question = {
136
+ "text": q.get("question", q.get("header", "")),
137
+ "header": q.get("header"),
138
+ "multiSelect": q.get("multiSelect", False),
139
+ }
140
+ raw_opts = q.get("options", [])
141
+ if raw_opts:
142
+ question["options"] = [
143
+ {"label": o.get("label", ""), "description": o.get("description")}
144
+ for o in raw_opts
145
+ if isinstance(o, dict)
146
+ ]
147
+ normalized_questions.append(question)
148
+ if not normalized_questions:
149
+ return
150
+
151
+ # Identity from env (set by agents-cli at spawn).
152
+ mailbox_id = os.path.basename(
153
+ os.environ.get("AGENTS_MAILBOX_DIR", "").rstrip("/")
154
+ ) or session_id
155
+
156
+ hostname = os.environ.get("AGENTS_SYNC_MACHINE_ID") or socket.gethostname()
157
+ host = hostname.split(".")[0].strip().lower()
158
+ host = re.sub(r"[^a-z0-9_-]", "-", host) or "unknown"
159
+
160
+ runtime = os.environ.get("AGENTS_RUNTIME", "headless")
161
+
162
+ safe_session_id = re.sub(r"[^A-Za-z0-9._-]", "-", session_id)
163
+ block_id = f"block-{safe_session_id}"
164
+ block = {
165
+ "blockId": block_id,
166
+ "sessionId": session_id,
167
+ "mailboxId": mailbox_id,
168
+ "host": host,
169
+ "runtime": runtime,
170
+ "ts": datetime.now(timezone.utc).isoformat(),
171
+ "questions": normalized_questions,
172
+ }
173
+
174
+ # Python's expanduser() ignores HOME on Windows, while agents-cli honors a
175
+ # HOME override on every platform. Use the same anchor so hooks and the CLI
176
+ # always read/write one feed store (including temp-home and sandbox runs).
177
+ home = os.environ.get("HOME") or os.path.expanduser("~")
178
+ feed_dir = os.path.join(home, ".agents", ".history", "feed")
179
+ os.makedirs(feed_dir, exist_ok=True)
180
+
181
+ target = os.path.join(feed_dir, f"{block_id}.json")
182
+ fd, tmp = tempfile.mkstemp(dir=feed_dir, suffix=".tmp")
183
+ try:
184
+ with os.fdopen(fd, "w") as f:
185
+ json.dump(block, f, indent=2)
186
+ os.rename(tmp, target)
187
+ except Exception:
188
+ try:
189
+ os.unlink(tmp)
190
+ except Exception:
191
+ pass
192
+
193
+
194
+ if __name__ == "__main__":
195
+ try:
196
+ main()
197
+ except Exception:
198
+ pass # fail open
199
+ `;
200
+ /** Manifest entry for the feed-publish hook, matching the ManifestHook shape. */
201
+ export const FEED_PUBLISH_HOOK_MANIFEST = {
202
+ name: 'feed-publish',
203
+ events: ['PreToolUse'],
204
+ matcher: 'AskUserQuestion',
205
+ script: '10-feed-publish.py',
206
+ timeout: 5,
207
+ };
208
+ /**
209
+ * Install the feed-publish hook script into the user hooks dir and add its
210
+ * manifest entry to the user agents.yaml. The system repo is an auto-pulled,
211
+ * read-only mirror, so runtime-managed hooks must never write there.
212
+ * Idempotent -- skips if the script is already present and up to date.
213
+ */
214
+ export function ensureFeedPublishHook(userAgentsDir = getUserAgentsDir()) {
215
+ try {
216
+ const hooksDir = path.join(userAgentsDir, 'hooks');
217
+ const scriptPath = path.join(hooksDir, '10-feed-publish.py');
218
+ fs.mkdirSync(hooksDir, { recursive: true });
219
+ let installed = false;
220
+ if (!fs.existsSync(scriptPath) || fs.readFileSync(scriptPath, 'utf-8') !== FEED_PUBLISH_HOOK_SCRIPT) {
221
+ const tmpScript = `${scriptPath}.${process.pid}.tmp`;
222
+ fs.writeFileSync(tmpScript, FEED_PUBLISH_HOOK_SCRIPT, { mode: 0o755 });
223
+ fs.renameSync(tmpScript, scriptPath);
224
+ installed = true;
225
+ }
226
+ const agentsYamlPath = path.join(userAgentsDir, 'agents.yaml');
227
+ const yamlDoc = fs.existsSync(agentsYamlPath)
228
+ ? yaml.parseDocument(fs.readFileSync(agentsYamlPath, 'utf-8'))
229
+ : new yaml.Document({});
230
+ if (yamlDoc.errors.length > 0) {
231
+ throw new Error(`Cannot install feed hook: ${agentsYamlPath} is invalid YAML`);
232
+ }
233
+ if (!yamlDoc.getIn(['hooks', 'feed-publish'])) {
234
+ yamlDoc.setIn(['hooks', 'feed-publish'], {
235
+ agents: ['claude'],
236
+ events: ['PreToolUse'],
237
+ matcher: 'AskUserQuestion',
238
+ script: '10-feed-publish.py',
239
+ timeout: 5,
240
+ });
241
+ const tmpYaml = `${agentsYamlPath}.${process.pid}.tmp`;
242
+ fs.writeFileSync(tmpYaml, String(yamlDoc));
243
+ fs.renameSync(tmpYaml, agentsYamlPath);
244
+ installed = true;
245
+ }
246
+ return { installed };
247
+ }
248
+ catch (err) {
249
+ return { installed: false, error: err.message };
250
+ }
251
+ }
@@ -0,0 +1,38 @@
1
+ /** Print `msg` in red to stderr and exit the process with `code`. */
2
+ export declare function die(msg: string, code?: number): never;
3
+ /**
4
+ * Truncate `s` to at most `max` characters, appending a single-char ellipsis
5
+ * (`…`) when shortened. Character-count based (not ANSI/width aware — use
6
+ * `truncateToWidth` from `session/width.ts` for colored strings).
7
+ */
8
+ export declare function truncate(s: string, max: number): string;
9
+ /**
10
+ * Format an ISO timestamp as a compact relative age: "just now", "5m ago",
11
+ * "3h ago", "2d ago". The canonical short form — the long "5 minutes ago"
12
+ * variant that once lived in `cloud.ts` is deliberately dropped. (For the
13
+ * session-list long form with calendar fallback, see
14
+ * `formatRelativeTime` in `session/relative-time.ts`.)
15
+ */
16
+ export declare function relTime(iso: string): string;
17
+ /** Format a millisecond duration as "45s", "3m", "2h 5m", "1d 3h". */
18
+ export declare function humanDuration(ms: number): string;
19
+ /**
20
+ * Visible column width of `s`, ignoring ANSI SGR color codes (e.g. chalk
21
+ * wrappers). Matches the full CSI sequence including the `\x1b` escape.
22
+ */
23
+ export declare function visibleWidth(s: string): number;
24
+ /** Pad `s` with trailing spaces to a target character width. */
25
+ export declare function padRight(s: string, width: number): string;
26
+ /** Pad `s` with trailing spaces to a target *visible* width (ANSI-aware). */
27
+ export declare function padVisible(s: string, width: number): string;
28
+ /** True when `--json` was passed or stdout is not a TTY. */
29
+ export declare function isJsonMode(opts: {
30
+ json?: boolean;
31
+ }): boolean;
32
+ /** Read all of stdin synchronously and return it UTF-8 decoded and trimmed. */
33
+ export declare function readStdinSync(): string;
34
+ /**
35
+ * Wrap `text` in an OSC 8 hyperlink to `filePath` (as a `file://` URL) when
36
+ * stdout is a TTY; otherwise return `text` unchanged.
37
+ */
38
+ export declare function termLink(text: string, filePath: string): string;
@@ -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: HookCacheConfig;
43
+ cache?: HookCacheConfig | null;
44
+ matches?: HookMatches;
38
45
  paths?: HookShimPaths;
39
46
  }): string;
40
47
  /**