@phnx-labs/agents-cli 1.20.92 → 1.21.0

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 (146) hide show
  1. package/CHANGELOG.md +385 -0
  2. package/README.md +3 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/beta.js +3 -5
  5. package/dist/commands/doctor.js +23 -0
  6. package/dist/commands/events.js +91 -1
  7. package/dist/commands/exec.js +13 -0
  8. package/dist/commands/mailboxes.js +39 -1
  9. package/dist/commands/message.js +12 -1
  10. package/dist/commands/packages.js +14 -5
  11. package/dist/commands/projects.d.ts +38 -0
  12. package/dist/commands/projects.js +355 -48
  13. package/dist/commands/routines.js +27 -1
  14. package/dist/commands/rules.js +6 -0
  15. package/dist/commands/secrets.d.ts +17 -0
  16. package/dist/commands/secrets.js +198 -7
  17. package/dist/commands/send.d.ts +14 -12
  18. package/dist/commands/send.js +105 -35
  19. package/dist/commands/sessions.d.ts +17 -0
  20. package/dist/commands/sessions.js +16 -8
  21. package/dist/commands/setup-browser.js +5 -1
  22. package/dist/commands/setup-fleet.js +5 -0
  23. package/dist/commands/setup-preferences.d.ts +53 -0
  24. package/dist/commands/setup-preferences.js +142 -0
  25. package/dist/commands/setup.js +8 -9
  26. package/dist/commands/ssh.js +220 -10
  27. package/dist/commands/sync.d.ts +2 -2
  28. package/dist/commands/sync.js +13 -12
  29. package/dist/commands/view.js +4 -0
  30. package/dist/commands/watchdog.js +26 -3
  31. package/dist/index.js +38 -6
  32. package/dist/lib/activity.d.ts +15 -0
  33. package/dist/lib/activity.js +26 -2
  34. package/dist/lib/auto-dispatch.d.ts +6 -1
  35. package/dist/lib/auto-dispatch.js +7 -2
  36. package/dist/lib/beta.d.ts +1 -1
  37. package/dist/lib/beta.js +1 -1
  38. package/dist/lib/channels/send.d.ts +83 -0
  39. package/dist/lib/channels/send.js +112 -0
  40. package/dist/lib/daemon.d.ts +13 -0
  41. package/dist/lib/daemon.js +80 -60
  42. package/dist/lib/device-config.d.ts +82 -0
  43. package/dist/lib/device-config.js +296 -0
  44. package/dist/lib/event-provenance.d.ts +19 -0
  45. package/dist/lib/event-provenance.js +48 -0
  46. package/dist/lib/events-ingest.d.ts +46 -0
  47. package/dist/lib/events-ingest.js +182 -0
  48. package/dist/lib/events.d.ts +17 -5
  49. package/dist/lib/events.js +59 -56
  50. package/dist/lib/feed.d.ts +1 -1
  51. package/dist/lib/feed.js +2 -0
  52. package/dist/lib/git.d.ts +13 -2
  53. package/dist/lib/git.js +38 -6
  54. package/dist/lib/linear-project-counts.d.ts +129 -0
  55. package/dist/lib/linear-project-counts.js +206 -0
  56. package/dist/lib/linear-projects.d.ts +62 -0
  57. package/dist/lib/linear-projects.js +144 -0
  58. package/dist/lib/mailbox-gc.js +30 -7
  59. package/dist/lib/mailbox.d.ts +14 -1
  60. package/dist/lib/mailbox.js +35 -3
  61. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  62. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  63. package/dist/lib/menubar/install-menubar.d.ts +5 -21
  64. package/dist/lib/menubar/install-menubar.js +35 -44
  65. package/dist/lib/menubar/notify-desktop.d.ts +17 -2
  66. package/dist/lib/menubar/notify-desktop.js +8 -2
  67. package/dist/lib/migrate.js +1 -1
  68. package/dist/lib/models.js +21 -11
  69. package/dist/lib/overdue.d.ts +1 -1
  70. package/dist/lib/overdue.js +54 -19
  71. package/dist/lib/project-import.d.ts +96 -0
  72. package/dist/lib/project-import.js +171 -0
  73. package/dist/lib/project-probe.d.ts +75 -0
  74. package/dist/lib/project-probe.js +160 -0
  75. package/dist/lib/project-resources.d.ts +8 -0
  76. package/dist/lib/project-resources.js +31 -3
  77. package/dist/lib/project-status.d.ts +32 -1
  78. package/dist/lib/project-status.js +82 -1
  79. package/dist/lib/projects.d.ts +6 -0
  80. package/dist/lib/projects.js +12 -0
  81. package/dist/lib/remote-agents-json.d.ts +14 -1
  82. package/dist/lib/remote-agents-json.js +21 -3
  83. package/dist/lib/routine-notify.d.ts +11 -0
  84. package/dist/lib/routine-notify.js +22 -0
  85. package/dist/lib/routines.d.ts +53 -0
  86. package/dist/lib/routines.js +105 -4
  87. package/dist/lib/rules/run-sync.d.ts +18 -0
  88. package/dist/lib/rules/run-sync.js +92 -0
  89. package/dist/lib/run-notify.js +3 -0
  90. package/dist/lib/scheduler.js +12 -2
  91. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  92. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  93. package/dist/lib/secrets/audit.d.ts +1 -1
  94. package/dist/lib/secrets/audit.js +53 -10
  95. package/dist/lib/secrets/index.d.ts +4 -5
  96. package/dist/lib/secrets/index.js +4 -5
  97. package/dist/lib/secrets/list-filter.d.ts +20 -5
  98. package/dist/lib/secrets/list-filter.js +22 -6
  99. package/dist/lib/secrets/usage-db.d.ts +106 -0
  100. package/dist/lib/secrets/usage-db.js +236 -0
  101. package/dist/lib/session/active.d.ts +16 -2
  102. package/dist/lib/session/active.js +8 -4
  103. package/dist/lib/session/bundle.js +5 -1
  104. package/dist/lib/session/remote-active.d.ts +5 -1
  105. package/dist/lib/session/remote-active.js +4 -1
  106. package/dist/lib/session/remote-list.d.ts +1 -15
  107. package/dist/lib/session/remote-list.js +22 -111
  108. package/dist/lib/session/sync/config.d.ts +13 -5
  109. package/dist/lib/session/sync/config.js +21 -13
  110. package/dist/lib/sqlite.js +28 -1
  111. package/dist/lib/startup/command-registry.d.ts +0 -2
  112. package/dist/lib/startup/command-registry.js +1 -5
  113. package/dist/lib/state.d.ts +19 -2
  114. package/dist/lib/state.js +99 -10
  115. package/dist/lib/sync-umbrella.d.ts +11 -23
  116. package/dist/lib/sync-umbrella.js +14 -45
  117. package/dist/lib/teams/agents.d.ts +35 -3
  118. package/dist/lib/teams/agents.js +52 -8
  119. package/dist/lib/teams/scheduler.d.ts +29 -2
  120. package/dist/lib/teams/scheduler.js +60 -16
  121. package/dist/lib/types.d.ts +22 -5
  122. package/dist/lib/versions.d.ts +6 -0
  123. package/dist/lib/versions.js +6 -4
  124. package/package.json +1 -2
  125. package/dist/commands/drive.d.ts +0 -10
  126. package/dist/commands/drive.js +0 -183
  127. package/dist/commands/hq.d.ts +0 -2
  128. package/dist/commands/hq.js +0 -58
  129. package/dist/commands/sessions-sync.d.ts +0 -17
  130. package/dist/commands/sessions-sync.js +0 -135
  131. package/dist/commands/sync-provision.d.ts +0 -23
  132. package/dist/commands/sync-provision.js +0 -107
  133. package/dist/lib/drive-sync.d.ts +0 -45
  134. package/dist/lib/drive-sync.js +0 -238
  135. package/dist/lib/hq/floor.d.ts +0 -87
  136. package/dist/lib/hq/floor.js +0 -243
  137. package/dist/lib/session/sync/crdt.d.ts +0 -44
  138. package/dist/lib/session/sync/crdt.js +0 -119
  139. package/dist/lib/session/sync/manifest.d.ts +0 -62
  140. package/dist/lib/session/sync/manifest.js +0 -100
  141. package/dist/lib/session/sync/provision.d.ts +0 -49
  142. package/dist/lib/session/sync/provision.js +0 -91
  143. package/dist/lib/session/sync/r2.d.ts +0 -32
  144. package/dist/lib/session/sync/r2.js +0 -121
  145. package/dist/lib/session/sync/sync.d.ts +0 -106
  146. package/dist/lib/session/sync/sync.js +0 -374
@@ -16,11 +16,9 @@ import * as path from 'path';
16
16
  import * as os from 'os';
17
17
  import { createHash } from 'node:crypto';
18
18
  import { gzipSync, gunzipSync } from 'node:zlib';
19
- import { parseSshConnection } from './session/provenance.js';
20
19
  import { ensureLockTarget, withFileLock } from './fs-atomic.js';
21
20
  import { getUserAgentsDir } from './state.js';
22
- import { resolveActor } from './actor.js';
23
- import { machineId } from './machine-id.js';
21
+ import { stampProvenance, resetEventProvenanceForTest } from './event-provenance.js';
24
22
  /** Lazy perf warehouse write — avoids a hard cycle at module load. */
25
23
  function recordPerfTiming(payload) {
26
24
  try {
@@ -72,9 +70,50 @@ function isDisabled() {
72
70
  const DIR_MODE = 0o700;
73
71
  /** File permissions (owner read/write only). */
74
72
  const FILE_MODE = 0o600;
73
+ /**
74
+ * Every {@link EventType}, as a runtime-checkable table.
75
+ *
76
+ * Typed `Record<EventType, true>` on purpose: the object literal is
77
+ * exhaustiveness-checked at COMPILE time, so adding a member to the union
78
+ * without adding it here fails `tsc`. That is what keeps the runtime validator
79
+ * (`isEventType`, used by `agents events emit` to reject an unknown kind from an
80
+ * out-of-process producer) from silently drifting behind the union.
81
+ */
82
+ const EVENT_TYPE_TABLE = {
83
+ 'agent.run.start': true, 'agent.run.end': true, 'agent.spawn.start': true, 'agent.spawn.end': true,
84
+ 'version.install': true, 'version.switch': true, 'version.remove': true,
85
+ 'skill.install': true, 'skill.remove': true,
86
+ 'browser.launch': true, 'browser.close': true, 'browser.navigate': true, 'browser.screenshot': true,
87
+ 'secrets.get': true, 'secrets.unlocked': true, 'secrets.create': true, 'secrets.import': true, 'secrets.export': true, 'secrets.view': true, 'secrets.set': true, 'secrets.delete': true, 'secrets.rename': true,
88
+ 'cloud.dispatch': true, 'cloud.complete': true, 'cloud.cancel': true, 'cloud.message': true,
89
+ 'teams.create': true, 'teams.add': true, 'teams.start': true, 'teams.complete': true, 'teams.disband': true,
90
+ 'hook.fire': true, 'hook.complete': true, 'hook.error': true,
91
+ 'mcp.add': true, 'mcp.remove': true, 'mcp.register': true,
92
+ 'resource.sync': true,
93
+ 'rotation.resolved': true,
94
+ 'command.start': true, 'command.end': true,
95
+ 'perf.timing': true,
96
+ 'session.start': true, 'session.end': true,
97
+ 'webhook.received': true, 'webhook.authorized': true, 'webhook.rejected': true, 'webhook.matched': true,
98
+ 'webhook.fired': true, 'webhook.handler.start': true, 'webhook.handler.end': true,
99
+ 'plan.created': true, 'pr.opened': true, 'pr.merged': true, 'worktree.created': true,
100
+ 'worktree.removed': true, 'commit.created': true, 'pushed': true, 'subagent.spawned': true,
101
+ 'artifact.created': true, 'task.completed': true, 'checklist.created': true, 'status.posted': true,
102
+ 'file.edited': true,
103
+ 'factory.command': true, 'factory.action': true, 'factory.uri': true, 'factory.launch': true,
104
+ 'friction': true, 'error': true, 'warn': true, 'info': true, 'debug': true,
105
+ };
106
+ /** Every known event kind. Derived from {@link EVENT_TYPE_TABLE}, never hand-listed. */
107
+ export const EVENT_TYPES = Object.keys(EVENT_TYPE_TABLE);
108
+ const EVENT_TYPE_SET = new Set(EVENT_TYPES);
109
+ /** Runtime guard for an event kind arriving from outside this process. */
110
+ export function isEventType(value) {
111
+ return EVENT_TYPE_SET.has(value);
112
+ }
75
113
  const AUDIT_EVENTS = new Set([
76
114
  'command.start', 'command.end',
77
- 'secrets.get', 'secrets.unlocked', 'secrets.set', 'secrets.delete', 'secrets.rename',
115
+ 'secrets.get', 'secrets.unlocked', 'secrets.create', 'secrets.import', 'secrets.export', 'secrets.view',
116
+ 'secrets.set', 'secrets.delete', 'secrets.rename',
78
117
  'teams.create', 'teams.add', 'teams.start', 'teams.complete', 'teams.disband',
79
118
  'cloud.dispatch', 'cloud.complete', 'cloud.cancel', 'cloud.message',
80
119
  'version.install', 'version.switch', 'version.remove',
@@ -82,6 +121,11 @@ const AUDIT_EVENTS = new Set([
82
121
  'mcp.add', 'mcp.remove', 'mcp.register',
83
122
  'rotation.resolved',
84
123
  'session.start', 'session.end',
124
+ // An external process reaching into the user's editor (the CLI's
125
+ // vscodium-agent backend driving `/spawn` / `/inject` / `/focus`) is a
126
+ // "who reached in from outside" fact, which is what the audit lane answers.
127
+ // The other factory.* kinds are ordinary info — a palette press is not audit.
128
+ 'factory.uri',
85
129
  ]);
86
130
  export function levelFor(event) {
87
131
  if (event === 'warn')
@@ -283,59 +327,21 @@ export function detectCaller(env = process.env, stdoutIsTTY = Boolean(process.st
283
327
  }
284
328
  return { kind: stdoutIsTTY ? 'terminal' : 'script' };
285
329
  }
286
- /**
287
- * Who is running this process and from where. Derived once per process from the
288
- * OS user and $SSH_CONNECTION (via the same parser the sessions layer uses), then
289
- * cached — provenance can't change mid-process, so every emit() pays for it once.
290
- */
291
- let _origin;
292
- function auditOrigin() {
293
- if (_origin)
294
- return _origin;
295
- let osUser = 'unknown';
296
- try {
297
- osUser = os.userInfo().username;
298
- }
299
- catch {
300
- // Container/edge cases where the uid has no passwd entry.
301
- }
302
- const ssh = process.env.SSH_CONNECTION ? parseSshConnection(process.env.SSH_CONNECTION) : undefined;
303
- const actor = resolveActor();
304
- _origin = {
305
- osUser,
306
- transport: ssh ? 'ssh' : 'local',
307
- ...(ssh ? { sshClientIp: ssh.clientIp } : {}),
308
- actor: actor.id,
309
- kind: actor.kind,
310
- };
311
- return _origin;
312
- }
313
- /** This machine's normalized device id, resolved once — it can't change mid-process. */
314
- let _machineId;
315
- function cachedMachineId() {
316
- return (_machineId ??= machineId());
317
- }
318
- function resolveProvenance(env = process.env) {
319
- const p = {};
320
- const sessionId = env.AGENT_SESSION_ID || env.AGENTS_SESSION_ID;
321
- if (sessionId)
322
- p.sessionId = sessionId;
323
- if (env.AGENTS_AGENT_NAME)
324
- p.agent = env.AGENTS_AGENT_NAME;
325
- if (env.AGENT_LAUNCH_ID)
326
- p.launchId = env.AGENT_LAUNCH_ID;
327
- if (env.AGENTS_PARENT_SESSION_ID)
328
- p.parentSessionId = env.AGENTS_PARENT_SESSION_ID;
329
- return p;
330
- }
330
+ // ─── Audit attribution ────────────────────────────────────────────────────────
331
331
  // ─── Core API ─────────────────────────────────────────────────────────────────
332
332
  /**
333
333
  * Emit a structured event to the append-only audit log.
334
334
  *
335
335
  * @param event - The event type
336
336
  * @param payload - Event-specific data (agent, version, cwd, etc.)
337
+ * @param overrides - Envelope fields the CALLER owns rather than the writer.
338
+ * Only `ts` today: a batched out-of-process producer (`agents events emit`)
339
+ * records when each event HAPPENED, but flushes them together later, so
340
+ * stamping write-time would collapse a whole batch onto the flush instant and
341
+ * corrupt every `--since` boundary. `ts` stays in RESERVED_META_KEYS so a
342
+ * *payload* still cannot inject it — this explicit channel is the only way in.
337
343
  */
338
- export function emit(event, payload = {}) {
344
+ export function emit(event, payload = {}, overrides = {}) {
339
345
  if (isDisabled())
340
346
  return;
341
347
  try {
@@ -344,13 +350,12 @@ export function emit(event, payload = {}) {
344
350
  const safePayload = sanitizePayload(payload);
345
351
  const record = {
346
352
  // Provenance floor first: env-sourced defaults an explicit payload overrides.
347
- ...resolveProvenance(),
353
+ ...stampProvenance(),
348
354
  ...safePayload,
349
- ts: new Date().toISOString(),
355
+ ts: overrides.ts ?? new Date().toISOString(),
350
356
  tz: getTimezoneOffset(),
351
357
  tzName: getTimezoneName(),
352
358
  hostname: os.hostname(),
353
- machineId: cachedMachineId(),
354
359
  platform: os.platform(),
355
360
  arch: os.arch(),
356
361
  pid: process.pid,
@@ -359,7 +364,6 @@ export function emit(event, payload = {}) {
359
364
  level: levelFor(event),
360
365
  caller: caller.kind,
361
366
  ...(caller.session ? { session: caller.session } : {}),
362
- ...auditOrigin(),
363
367
  };
364
368
  const line = JSON.stringify(record) + '\n';
365
369
  const logPath = eventsPath();
@@ -867,8 +871,7 @@ export function getLogsPath() {
867
871
  }
868
872
  export function _resetForTest(overrideEventsPath) {
869
873
  _eventsPath = overrideEventsPath;
870
- _origin = undefined;
871
- _machineId = undefined;
874
+ resetEventProvenanceForTest();
872
875
  _chmoddedPath = undefined;
873
876
  lastRotationCheck = 0;
874
877
  }
@@ -12,7 +12,7 @@ export interface MessageReceipt {
12
12
  /** The message id this receipt describes. */
13
13
  msgId: string;
14
14
  /** Delivery lifecycle state. */
15
- status: 'queued' | 'consumed' | 'continued';
15
+ status: 'queued' | 'consumed' | 'continued' | 'dropped' | 'expired';
16
16
  /** ISO-8601 timestamp of the state transition. */
17
17
  at: string;
18
18
  /** Optional sender label for the message. */
package/dist/lib/feed.js CHANGED
@@ -153,6 +153,8 @@ const RECEIPT_STATUS_RANK = {
153
153
  queued: 0,
154
154
  consumed: 1,
155
155
  continued: 2,
156
+ dropped: 3,
157
+ expired: 3,
156
158
  };
157
159
  /**
158
160
  * Record a delivery-receipt transition for a message tied to a block.
package/dist/lib/git.d.ts CHANGED
@@ -41,8 +41,12 @@ export declare function assertValidBranchName(branch: string): void;
41
41
  *
42
42
  * Prefer this over `git.push(remote, branch)` whenever the branch comes from
43
43
  * repo state rather than a hard-coded literal.
44
+ *
45
+ * Pass `targetBranch` to push the local `branch` to a differently-named remote
46
+ * branch (`git push origin <branch>:<targetBranch>`) — used when publishing the
47
+ * working tree to a branch other than the checked-out one.
44
48
  */
45
- export declare function pushOrigin(git: SimpleGit, branch: string): Promise<void>;
49
+ export declare function pushOrigin(git: SimpleGit, branch: string, targetBranch?: string): Promise<void>;
46
50
  /** Parsed representation of a git source string (GitHub, generic URL, or local path). */
47
51
  export interface GitSource {
48
52
  type: 'github' | 'url' | 'local';
@@ -124,6 +128,8 @@ export declare function resolveGitHubUsername(): Promise<string | null>;
124
128
  * Get the remote URL for origin in a git repo.
125
129
  */
126
130
  export declare function getRemoteUrl(repoPath: string): Promise<string | null>;
131
+ /** The repo's checked-out branch, or 'main' on a detached HEAD / read failure. */
132
+ export declare function getCurrentBranch(repoPath: string): Promise<string>;
127
133
  /**
128
134
  * Canonical `host/owner/repo` form of a git remote, transport-agnostic, so the
129
135
  * same repo cloned over SSH vs HTTPS compares equal. Strips protocol, any
@@ -158,8 +164,13 @@ export type CommitAndPushResult = {
158
164
  * Clean tree + local ahead of origin still pushes — "nothing to commit" is not
159
165
  * "nothing to push". Reports "already up to date" only when `ahead === 0` and
160
166
  * there is nothing to commit.
167
+ *
168
+ * `targetBranch` pushes the working tree to a differently-named remote branch
169
+ * (`<current>:<targetBranch>`) and is reported back as the result `branch`, so
170
+ * callers that print a branch-scoped URL reference where the commit actually
171
+ * landed — not the checked-out branch.
161
172
  */
162
- export declare function commitAndPush(repoPath: string, message: string): Promise<CommitAndPushResult>;
173
+ export declare function commitAndPush(repoPath: string, message: string, targetBranch?: string): Promise<CommitAndPushResult>;
163
174
  /**
164
175
  * Check if repo has uncommitted changes.
165
176
  */
package/dist/lib/git.js CHANGED
@@ -77,9 +77,18 @@ export function assertValidBranchName(branch) {
77
77
  *
78
78
  * Prefer this over `git.push(remote, branch)` whenever the branch comes from
79
79
  * repo state rather than a hard-coded literal.
80
+ *
81
+ * Pass `targetBranch` to push the local `branch` to a differently-named remote
82
+ * branch (`git push origin <branch>:<targetBranch>`) — used when publishing the
83
+ * working tree to a branch other than the checked-out one.
80
84
  */
81
- export async function pushOrigin(git, branch) {
85
+ export async function pushOrigin(git, branch, targetBranch) {
82
86
  assertValidBranchName(branch);
87
+ if (targetBranch && targetBranch !== branch) {
88
+ assertValidBranchName(targetBranch);
89
+ await git.raw(['push', '--', 'origin', `${branch}:${targetBranch}`]);
90
+ return;
91
+ }
83
92
  await git.raw(['push', '--', 'origin', branch]);
84
93
  }
85
94
  /**
@@ -435,6 +444,16 @@ export async function getRemoteUrl(repoPath) {
435
444
  return null;
436
445
  }
437
446
  }
447
+ /** The repo's checked-out branch, or 'main' on a detached HEAD / read failure. */
448
+ export async function getCurrentBranch(repoPath) {
449
+ try {
450
+ const status = await simpleGit(repoPath).status();
451
+ return status.current || 'main';
452
+ }
453
+ catch {
454
+ return 'main';
455
+ }
456
+ }
438
457
  /**
439
458
  * Canonical `host/owner/repo` form of a git remote, transport-agnostic, so the
440
459
  * same repo cloned over SSH vs HTTPS compares equal. Strips protocol, any
@@ -494,13 +513,23 @@ export async function checkGitHubRepoExists(owner, repo) {
494
513
  * Clean tree + local ahead of origin still pushes — "nothing to commit" is not
495
514
  * "nothing to push". Reports "already up to date" only when `ahead === 0` and
496
515
  * there is nothing to commit.
516
+ *
517
+ * `targetBranch` pushes the working tree to a differently-named remote branch
518
+ * (`<current>:<targetBranch>`) and is reported back as the result `branch`, so
519
+ * callers that print a branch-scoped URL reference where the commit actually
520
+ * landed — not the checked-out branch.
497
521
  */
498
- export async function commitAndPush(repoPath, message) {
522
+ export async function commitAndPush(repoPath, message, targetBranch) {
499
523
  try {
500
524
  const git = simpleGit(repoPath);
501
525
  let status = await git.status();
502
526
  const branch = status.current || 'main';
503
527
  assertValidBranchName(branch);
528
+ if (targetBranch)
529
+ assertValidBranchName(targetBranch);
530
+ // The branch the commit ends up on remotely — the checked-out branch unless
531
+ // an explicit target was requested.
532
+ const pushedBranch = targetBranch || branch;
504
533
  let committed = false;
505
534
  if (status.files.length > 0) {
506
535
  await git.add('-A');
@@ -509,7 +538,10 @@ export async function commitAndPush(repoPath, message) {
509
538
  status = await git.status();
510
539
  }
511
540
  const ahead = status.ahead ?? 0;
512
- if (!committed && ahead === 0) {
541
+ // A same-branch push short-circuits when there is nothing new; a push to a
542
+ // different target branch must still run even from a clean, non-ahead tree,
543
+ // since the target may not carry these commits yet.
544
+ if (!committed && ahead === 0 && pushedBranch === branch) {
513
545
  return {
514
546
  success: true,
515
547
  detail: 'already up to date',
@@ -521,12 +553,12 @@ export async function commitAndPush(repoPath, message) {
521
553
  // Capture remote tip before push for a real ref range in the detail string.
522
554
  let before = '';
523
555
  try {
524
- before = (await git.raw(['rev-parse', '--short=8', `origin/${branch}`])).trim();
556
+ before = (await git.raw(['rev-parse', '--short=8', `origin/${pushedBranch}`])).trim();
525
557
  }
526
558
  catch {
527
559
  /* origin/<branch> may not exist yet (first push) */
528
560
  }
529
- await pushOrigin(git, branch);
561
+ await pushOrigin(git, branch, targetBranch);
530
562
  let after = '';
531
563
  try {
532
564
  after = (await git.raw(['rev-parse', '--short=8', 'HEAD'])).trim();
@@ -547,7 +579,7 @@ export async function commitAndPush(repoPath, message) {
547
579
  return {
548
580
  success: true,
549
581
  detail,
550
- branch,
582
+ branch: pushedBranch,
551
583
  committed,
552
584
  pushed: true,
553
585
  };
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Per-project Linear issue counts for the `agents projects status` card.
3
+ *
4
+ * When a project definition carries `linear.projectId` (set via
5
+ * `agents projects link <name> --linear`), the card shows one outcome line —
6
+ * `12/30 done · 5 in progress` — counted from the Linear GraphQL API by state
7
+ * TYPE (triage / backlog / unstarted / started / completed / canceled), never
8
+ * hardcoded state names, same convention as `auto-dispatch-linear.ts`.
9
+ *
10
+ * The same fetch also yields the **next milestone** — the earliest-dated
11
+ * milestone with unfinished issues — because each issue node carries its
12
+ * `projectMilestone`. A percentage tells you how far along a project is; the
13
+ * milestone tells you what it is due to hit next, which is the thing a person
14
+ * actually plans around. Deriving it here costs no extra request.
15
+ *
16
+ * This is a best-effort card enrichment, not an explicit command: every failure
17
+ * (no credential, offline, API error, timeout) degrades to `undefined` and the
18
+ * card simply omits the line — never a hang, never a throw. `--no-remote`
19
+ * skips it (it's network). The API key resolves through the same chain the rest
20
+ * of the stack uses: $LINEAR_API_KEY → macOS Keychain (`resolveLinearApiKey`)
21
+ * → the linear-cli config (`~/.linear-cli/config.json` `apiKey`).
22
+ *
23
+ * Paging is capped (10 × 250 issues) so a pathological project can't burn the
24
+ * budget; a capped fetch reports `truncated: true` and the card renders the
25
+ * total as a lower bound (`2500+ done`), never as the complete count.
26
+ */
27
+ /**
28
+ * The next checkpoint the project is working toward: the earliest-dated
29
+ * milestone that still has unfinished issues. A percentage says how far along
30
+ * the project is; this says what it is due to hit next.
31
+ */
32
+ export interface LinearMilestone {
33
+ name: string;
34
+ /** `YYYY-MM-DD` as Linear stores it. Absent when the milestone has no date. */
35
+ targetDate?: string;
36
+ /** Issues in this milestone in a `completed`-type state. */
37
+ done: number;
38
+ /**
39
+ * Issues assigned to this milestone. Legitimately `0` — a milestone can be
40
+ * declared with a date long before any issue is filed under it (that is the
41
+ * state of every milestone in this repo's own Linear project), and such a
42
+ * milestone is still the next checkpoint. The card omits the fraction rather
43
+ * than printing a meaningless `0/0`.
44
+ */
45
+ total: number;
46
+ }
47
+ /** A milestone as the project declares it, independent of any issue. */
48
+ export interface LinearMilestoneNode {
49
+ id?: string;
50
+ name?: string;
51
+ targetDate?: string | null;
52
+ }
53
+ /** The counts the card renders. `total` counts every issue in the project. */
54
+ export interface LinearProjectCounts {
55
+ /** Issues in a `completed`-type state. */
56
+ done: number;
57
+ /** All issues in the project (any state type, including canceled). */
58
+ total: number;
59
+ /** Issues in a `started`-type state. */
60
+ inProgress: number;
61
+ /**
62
+ * True when the page cap cut the fetch short — `total` is then a LOWER
63
+ * bound (rendered `2500+`), never presented as the complete count.
64
+ */
65
+ truncated?: boolean;
66
+ /**
67
+ * The next unfinished milestone, when the project has one. Derived from the
68
+ * SAME paged issue fetch as the counts — a milestone is only ever a grouping
69
+ * of these issues, so asking Linear again would spend a second round trip to
70
+ * learn what the first already said.
71
+ */
72
+ nextMilestone?: LinearMilestone;
73
+ }
74
+ /** One issue node as the query selects it. */
75
+ export interface LinearIssueNode {
76
+ state?: {
77
+ type?: string;
78
+ } | null;
79
+ projectMilestone?: {
80
+ id?: string;
81
+ name?: string;
82
+ targetDate?: string | null;
83
+ } | null;
84
+ }
85
+ /** The GraphQL response shape this module consumes (recorded for the tests). */
86
+ export interface LinearIssuesResponse {
87
+ issues?: {
88
+ nodes?: LinearIssueNode[];
89
+ pageInfo?: {
90
+ hasNextPage?: boolean;
91
+ endCursor?: string | null;
92
+ };
93
+ };
94
+ /** Only the FIRST page asks for this — the milestone list does not paginate. */
95
+ project?: {
96
+ projectMilestones?: {
97
+ nodes?: LinearMilestoneNode[];
98
+ };
99
+ } | null;
100
+ }
101
+ /**
102
+ * Pure mapping: a Linear issues response → card counts, grouping by state
103
+ * type. Defensive at the boundary — a missing `issues`/`nodes` yields zeros,
104
+ * an issue with no state still counts toward `total`.
105
+ */
106
+ export declare function countsFromIssuesResponse(data: LinearIssuesResponse): LinearProjectCounts;
107
+ /**
108
+ * Pick the milestone the project is working toward next.
109
+ *
110
+ * The **declared** list is authoritative for which milestones exist, their
111
+ * names, and their dates; issues only supply progress. Deriving the list from
112
+ * issues instead looks tempting (it costs no extra request) and is wrong: a
113
+ * milestone with nothing filed under it yet would be invisible, and that is the
114
+ * common case — every milestone in this repo's own Linear project has zero
115
+ * issues assigned, so an issue-derived list showed nothing at all.
116
+ *
117
+ * Next = the earliest-dated milestone that is not finished. A milestone with no
118
+ * issues counts as unfinished (it is upcoming work, not completed work). Undated
119
+ * milestones sort last, ties break by declaration order, so the answer is stable.
120
+ */
121
+ export declare function nextMilestone(declared: LinearMilestoneNode[], nodes: LinearIssueNode[]): LinearMilestone | undefined;
122
+ /**
123
+ * Fetch issue counts for one Linear project, paging `issues` filtered by
124
+ * project id. One shared AbortController bounds the WHOLE paged fetch at ~8s;
125
+ * any failure (no key, network, API error, abort) returns undefined so the
126
+ * card just omits the line. `fetchPage` is injectable for tests — the
127
+ * accumulator (cursor hand-off, cap) is the risky logic, not the HTTP.
128
+ */
129
+ export declare function fetchLinearProjectCounts(projectId: string, fetchPage?: (projectId: string, after: string | undefined, signal: AbortSignal) => Promise<LinearIssuesResponse | undefined>): Promise<LinearProjectCounts | undefined>;
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Per-project Linear issue counts for the `agents projects status` card.
3
+ *
4
+ * When a project definition carries `linear.projectId` (set via
5
+ * `agents projects link <name> --linear`), the card shows one outcome line —
6
+ * `12/30 done · 5 in progress` — counted from the Linear GraphQL API by state
7
+ * TYPE (triage / backlog / unstarted / started / completed / canceled), never
8
+ * hardcoded state names, same convention as `auto-dispatch-linear.ts`.
9
+ *
10
+ * The same fetch also yields the **next milestone** — the earliest-dated
11
+ * milestone with unfinished issues — because each issue node carries its
12
+ * `projectMilestone`. A percentage tells you how far along a project is; the
13
+ * milestone tells you what it is due to hit next, which is the thing a person
14
+ * actually plans around. Deriving it here costs no extra request.
15
+ *
16
+ * This is a best-effort card enrichment, not an explicit command: every failure
17
+ * (no credential, offline, API error, timeout) degrades to `undefined` and the
18
+ * card simply omits the line — never a hang, never a throw. `--no-remote`
19
+ * skips it (it's network). The API key resolves through the same chain the rest
20
+ * of the stack uses: $LINEAR_API_KEY → macOS Keychain (`resolveLinearApiKey`)
21
+ * → the linear-cli config (`~/.linear-cli/config.json` `apiKey`).
22
+ *
23
+ * Paging is capped (10 × 250 issues) so a pathological project can't burn the
24
+ * budget; a capped fetch reports `truncated: true` and the card renders the
25
+ * total as a lower bound (`2500+ done`), never as the complete count.
26
+ */
27
+ import * as fs from 'fs';
28
+ import * as os from 'os';
29
+ import * as path from 'path';
30
+ import { resolveLinearApiKey } from './auto-dispatch-linear.js';
31
+ const LINEAR_API = 'https://api.linear.app/graphql';
32
+ /** Overall budget across all pages — the card must never hang on Linear. */
33
+ const TIMEOUT_MS = 8_000;
34
+ const PAGE_SIZE = 250;
35
+ /** Hard page cap so a pathological project can't page forever within the budget. */
36
+ const MAX_PAGES = 10;
37
+ /**
38
+ * Pure mapping: a Linear issues response → card counts, grouping by state
39
+ * type. Defensive at the boundary — a missing `issues`/`nodes` yields zeros,
40
+ * an issue with no state still counts toward `total`.
41
+ */
42
+ export function countsFromIssuesResponse(data) {
43
+ const nodes = data.issues?.nodes ?? [];
44
+ let done = 0;
45
+ let inProgress = 0;
46
+ for (const n of nodes) {
47
+ const type = n?.state?.type;
48
+ if (type === 'completed')
49
+ done++;
50
+ else if (type === 'started')
51
+ inProgress++;
52
+ }
53
+ const counts = { done, total: nodes.length, inProgress };
54
+ const next = nextMilestone(data.project?.projectMilestones?.nodes ?? [], nodes);
55
+ if (next)
56
+ counts.nextMilestone = next;
57
+ return counts;
58
+ }
59
+ /**
60
+ * Pick the milestone the project is working toward next.
61
+ *
62
+ * The **declared** list is authoritative for which milestones exist, their
63
+ * names, and their dates; issues only supply progress. Deriving the list from
64
+ * issues instead looks tempting (it costs no extra request) and is wrong: a
65
+ * milestone with nothing filed under it yet would be invisible, and that is the
66
+ * common case — every milestone in this repo's own Linear project has zero
67
+ * issues assigned, so an issue-derived list showed nothing at all.
68
+ *
69
+ * Next = the earliest-dated milestone that is not finished. A milestone with no
70
+ * issues counts as unfinished (it is upcoming work, not completed work). Undated
71
+ * milestones sort last, ties break by declaration order, so the answer is stable.
72
+ */
73
+ export function nextMilestone(declared, nodes) {
74
+ // Progress per milestone id, from whatever issues do carry one.
75
+ const progress = new Map();
76
+ for (const n of nodes) {
77
+ const id = n?.projectMilestone?.id;
78
+ if (!id)
79
+ continue;
80
+ const p = progress.get(id) ?? { done: 0, total: 0 };
81
+ p.total++;
82
+ if (n.state?.type === 'completed')
83
+ p.done++;
84
+ progress.set(id, p);
85
+ }
86
+ const candidates = declared
87
+ .map((d, order) => {
88
+ if (!d?.id || typeof d.name !== 'string' || !d.name)
89
+ return undefined;
90
+ const p = progress.get(d.id) ?? { done: 0, total: 0 };
91
+ const m = { name: d.name, done: p.done, total: p.total, order };
92
+ if (d.targetDate)
93
+ m.targetDate = d.targetDate;
94
+ return m;
95
+ })
96
+ .filter((m) => m !== undefined)
97
+ // total 0 means "declared, nothing filed yet" — unfinished, not done.
98
+ .filter((m) => m.total === 0 || m.done < m.total);
99
+ if (candidates.length === 0)
100
+ return undefined;
101
+ candidates.sort((a, b) => {
102
+ if (a.targetDate && b.targetDate)
103
+ return a.targetDate < b.targetDate ? -1 : a.targetDate > b.targetDate ? 1 : a.order - b.order;
104
+ if (a.targetDate)
105
+ return -1;
106
+ if (b.targetDate)
107
+ return 1;
108
+ return a.order - b.order;
109
+ });
110
+ const { order: _order, ...m } = candidates[0];
111
+ return m;
112
+ }
113
+ /** $LINEAR_API_KEY → macOS Keychain → ~/.linear-cli/config.json. Null if none. */
114
+ function resolveApiKey() {
115
+ const fromChain = resolveLinearApiKey();
116
+ if (fromChain)
117
+ return fromChain;
118
+ try {
119
+ const cfg = JSON.parse(fs.readFileSync(path.join(os.homedir(), '.linear-cli', 'config.json'), 'utf8'));
120
+ return cfg.apiKey?.trim() || null;
121
+ }
122
+ catch {
123
+ return null;
124
+ }
125
+ }
126
+ /**
127
+ * Fetch issue counts for one Linear project, paging `issues` filtered by
128
+ * project id. One shared AbortController bounds the WHOLE paged fetch at ~8s;
129
+ * any failure (no key, network, API error, abort) returns undefined so the
130
+ * card just omits the line. `fetchPage` is injectable for tests — the
131
+ * accumulator (cursor hand-off, cap) is the risky logic, not the HTTP.
132
+ */
133
+ export async function fetchLinearProjectCounts(projectId, fetchPage = fetchLinearIssuesPage) {
134
+ const ctrl = new AbortController();
135
+ const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS);
136
+ try {
137
+ const all = [];
138
+ // Declared on the project, not on its issues — only page 0 asks for it.
139
+ let declared = [];
140
+ let after;
141
+ let truncated = false;
142
+ for (let page = 0;; page++) {
143
+ const data = await fetchPage(projectId, after, ctrl.signal);
144
+ if (!data)
145
+ return undefined;
146
+ if (page === 0)
147
+ declared = data.project?.projectMilestones?.nodes ?? [];
148
+ all.push(...(data.issues?.nodes ?? []));
149
+ const pi = data.issues?.pageInfo;
150
+ if (!pi?.hasNextPage || !pi.endCursor)
151
+ break;
152
+ if (page + 1 >= MAX_PAGES) {
153
+ // The cap cut the fetch short — total is a lower bound, say so.
154
+ truncated = true;
155
+ break;
156
+ }
157
+ after = pi.endCursor;
158
+ }
159
+ return {
160
+ ...countsFromIssuesResponse({
161
+ issues: { nodes: all },
162
+ project: { projectMilestones: { nodes: declared } },
163
+ }),
164
+ ...(truncated ? { truncated } : {}),
165
+ };
166
+ }
167
+ catch {
168
+ return undefined;
169
+ }
170
+ finally {
171
+ clearTimeout(timer);
172
+ }
173
+ }
174
+ /** One real GraphQL page; undefined on any HTTP/API-level failure. */
175
+ async function fetchLinearIssuesPage(projectId, after, signal) {
176
+ const apiKey = resolveApiKey();
177
+ if (!apiKey)
178
+ return undefined;
179
+ // The declared-milestone list rides along on the FIRST page only — it does
180
+ // not paginate, and re-requesting it per page would spend up to MAX_PAGES
181
+ // copies of the same answer.
182
+ const issuesSelection = 'issues(filter:{ project:{ id:{ eq:$p } } }, first:' +
183
+ PAGE_SIZE +
184
+ ', after:$after){ nodes{ state{ type } projectMilestone{ id } } pageInfo{ hasNextPage endCursor } }';
185
+ const milestonesSelection = 'project(id:$pid){ projectMilestones(first:50){ nodes{ id name targetDate } } }';
186
+ const first = after === undefined;
187
+ const res = await fetch(LINEAR_API, {
188
+ method: 'POST',
189
+ headers: { Authorization: apiKey, 'Content-Type': 'application/json' },
190
+ body: JSON.stringify({
191
+ query: first
192
+ ? `query($p:ID!, $pid:String!, $after:String){ ${issuesSelection} ${milestonesSelection} }`
193
+ : `query($p:ID!, $after:String){ ${issuesSelection} }`,
194
+ variables: first
195
+ ? { p: projectId, pid: projectId, after: null }
196
+ : { p: projectId, after },
197
+ }),
198
+ signal,
199
+ });
200
+ if (!res.ok)
201
+ return undefined;
202
+ const json = (await res.json());
203
+ if (json.errors?.length || !json.data)
204
+ return undefined;
205
+ return json.data;
206
+ }