@phnx-labs/agents-cli 1.22.65 → 1.22.68

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 (112) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +7 -2
  3. package/dist/bootstrap.js +3 -3
  4. package/dist/commands/apply.js +9 -2
  5. package/dist/commands/browser.js +46 -21
  6. package/dist/commands/daemon-test-harness.d.ts +1 -1
  7. package/dist/commands/daemon-test-harness.js +2 -2
  8. package/dist/commands/daemon.js +21 -22
  9. package/dist/commands/exec.js +40 -0
  10. package/dist/commands/monitors.js +2 -2
  11. package/dist/commands/repo.js +57 -19
  12. package/dist/commands/resume.d.ts +16 -0
  13. package/dist/commands/resume.js +41 -8
  14. package/dist/commands/routines.js +42 -21
  15. package/dist/commands/sessions-optimize.js +1 -1
  16. package/dist/commands/sessions.js +4 -11
  17. package/dist/commands/share.d.ts +5 -3
  18. package/dist/commands/share.js +33 -9
  19. package/dist/lib/accounting/account-pool-collect.d.ts +6 -4
  20. package/dist/lib/accounting/account-pool-collect.js +6 -4
  21. package/dist/lib/accounting/usage-ingest.js +4 -2
  22. package/dist/lib/accounting/usage-sync.d.ts +38 -93
  23. package/dist/lib/accounting/usage-sync.js +66 -210
  24. package/dist/lib/accounting/usage.d.ts +11 -3
  25. package/dist/lib/accounting/usage.js +23 -10
  26. package/dist/lib/auth-health.d.ts +8 -0
  27. package/dist/lib/auth-health.js +4 -4
  28. package/dist/lib/browser/caller-identity.d.ts +12 -0
  29. package/dist/lib/browser/caller-identity.js +19 -0
  30. package/dist/lib/browser/ipc.d.ts +37 -32
  31. package/dist/lib/browser/ipc.js +146 -94
  32. package/dist/lib/browser/task-index.d.ts +10 -2
  33. package/dist/lib/browser/task-index.js +22 -3
  34. package/dist/lib/daemon/account-state-daemon-service.d.ts +49 -9
  35. package/dist/lib/daemon/account-state-daemon-service.js +81 -18
  36. package/dist/lib/daemon/auth-sync-service.d.ts +4 -4
  37. package/dist/lib/daemon/auth-sync-service.js +17 -6
  38. package/dist/lib/daemon/catchup-service.d.ts +51 -0
  39. package/dist/lib/daemon/catchup-service.js +51 -0
  40. package/dist/lib/daemon/daemon.d.ts +12 -22
  41. package/dist/lib/daemon/daemon.js +463 -176
  42. package/dist/lib/daemon/runner.js +2 -0
  43. package/dist/lib/daemon/service.d.ts +22 -4
  44. package/dist/lib/daemon/service.js +2 -2
  45. package/dist/lib/daemon/supervisor.d.ts +55 -15
  46. package/dist/lib/daemon/supervisor.js +119 -29
  47. package/dist/lib/daemon/usage-sync-service.d.ts +4 -6
  48. package/dist/lib/daemon/usage-sync-service.js +22 -18
  49. package/dist/lib/daemon-health.js +36 -31
  50. package/dist/lib/daemon-services.d.ts +1 -1
  51. package/dist/lib/daemon-services.js +12 -2
  52. package/dist/lib/daemon-ticks.d.ts +9 -6
  53. package/dist/lib/daemon-ticks.js +14 -8
  54. package/dist/lib/devices/known-hosts.d.ts +15 -0
  55. package/dist/lib/devices/known-hosts.js +18 -0
  56. package/dist/lib/devices/registry.js +2 -0
  57. package/dist/lib/exec.js +11 -0
  58. package/dist/lib/feed/watch.d.ts +3 -0
  59. package/dist/lib/feed/watch.js +13 -3
  60. package/dist/lib/fleet-shared-repo-sync.d.ts +36 -0
  61. package/dist/lib/fleet-shared-repo-sync.js +333 -0
  62. package/dist/lib/fleet-shared-state.d.ts +38 -0
  63. package/dist/lib/fleet-shared-state.js +105 -0
  64. package/dist/lib/hosts/remote-cmd.d.ts +2 -0
  65. package/dist/lib/hosts/remote-cmd.js +12 -3
  66. package/dist/lib/lock-compromise.d.ts +8 -0
  67. package/dist/lib/lock-compromise.js +12 -0
  68. package/dist/lib/monitors/engine.d.ts +2 -1
  69. package/dist/lib/monitors/engine.js +9 -2
  70. package/dist/lib/refresh-coordinator.js +2 -0
  71. package/dist/lib/run-trace-sync.d.ts +13 -0
  72. package/dist/lib/run-trace-sync.js +77 -0
  73. package/dist/lib/secrets/push.d.ts +10 -0
  74. package/dist/lib/secrets/push.js +86 -7
  75. package/dist/lib/secrets/remote.d.ts +18 -6
  76. package/dist/lib/secrets/remote.js +29 -4
  77. package/dist/lib/secrets/reserved-sync.d.ts +28 -27
  78. package/dist/lib/secrets/reserved-sync.js +120 -102
  79. package/dist/lib/session/active.d.ts +3 -1
  80. package/dist/lib/session/active.js +2 -0
  81. package/dist/lib/session/actor-sidecar.d.ts +12 -0
  82. package/dist/lib/session/actor-sidecar.js +2 -0
  83. package/dist/lib/session/db.d.ts +5 -2
  84. package/dist/lib/session/db.js +54 -22
  85. package/dist/lib/session/discover.d.ts +12 -3
  86. package/dist/lib/session/discover.js +106 -25
  87. package/dist/lib/session/prompt.d.ts +11 -0
  88. package/dist/lib/session/prompt.js +25 -0
  89. package/dist/lib/session/recovery.d.ts +30 -2
  90. package/dist/lib/session/recovery.js +56 -4
  91. package/dist/lib/session/remote/remote-list.d.ts +17 -1
  92. package/dist/lib/session/remote/remote-list.js +29 -4
  93. package/dist/lib/session/remote/watch.d.ts +25 -2
  94. package/dist/lib/session/remote/watch.js +185 -11
  95. package/dist/lib/session/session-cache.d.ts +2 -1
  96. package/dist/lib/session/session-cache.js +1 -0
  97. package/dist/lib/session/types.d.ts +2 -0
  98. package/dist/lib/share/publish.d.ts +55 -9
  99. package/dist/lib/share/publish.js +87 -9
  100. package/dist/lib/share/worker-template.js +100 -8
  101. package/dist/lib/ssh-exec.d.ts +2 -0
  102. package/dist/lib/ssh-exec.js +20 -4
  103. package/dist/lib/teams/registry.js +2 -0
  104. package/dist/lib/testdata/daemon-health-writer.d.ts +1 -0
  105. package/dist/lib/testdata/daemon-health-writer.js +8 -0
  106. package/dist/lib/traces/sync.d.ts +7 -0
  107. package/dist/lib/traces/sync.js +9 -0
  108. package/dist/lib/usage-refresh.d.ts +8 -2
  109. package/dist/lib/usage-refresh.js +3 -3
  110. package/package.json +1 -1
  111. package/dist/lib/account-state-service.d.ts +0 -21
  112. package/dist/lib/account-state-service.js +0 -60
@@ -145,7 +145,23 @@ export declare function fetchPeerPreviewDigest(sessionId: string, machine: strin
145
145
  * `opts.sessionId` (with `tty`) prints the session id and resume command when
146
146
  * the SSH hop ends, so OpenSSH's `Shared connection … closed.` is not the last
147
147
  * thing on the local shell (RUSH-3227). Omit it for one-shot non-TTY renders.
148
+ *
149
+ * Resolves `'unreachable'` when the SSH connection itself failed — ssh could not
150
+ * launch (spawn error) or exited with {@link SSH_CONN_FAILURE_CODE} (255, its
151
+ * connect-failure convention) — as opposed to `'ok'` for a hop that actually
152
+ * reached the peer (whatever the remote command's own exit code). This lets a
153
+ * caller prefer the recorded device yet fall back locally when it is genuinely
154
+ * offline (PHNX-3626); callers that ignore `'unreachable'` behave exactly as
155
+ * before (it was `'ok'`).
156
+ */
157
+ /**
158
+ * Classify a finished SSH hop by its exit code: `'unreachable'` when the
159
+ * connection itself failed (ssh's {@link SSH_CONN_FAILURE_CODE} = 255, or a null
160
+ * code from a killed/never-launched child), else `'ok'` — the remote command ran,
161
+ * whatever its own exit status. Pure so the offline-device fallback is unit-tested
162
+ * without a live SSH hop (PHNX-3626).
148
163
  */
164
+ export declare function peerHopOutcome(code: number | null): 'ok' | 'unreachable';
149
165
  export declare function peerHopCloseNotice(opts: {
150
166
  tty?: boolean;
151
167
  sessionId?: string;
@@ -154,4 +170,4 @@ export declare function runOnPeer(args: string[], machine: string, opts?: {
154
170
  tty?: boolean;
155
171
  env?: Record<string, string>;
156
172
  sessionId?: string;
157
- }): Promise<'ok' | 'no-target'>;
173
+ }): Promise<'ok' | 'no-target' | 'unreachable'>;
@@ -582,7 +582,25 @@ export async function fetchPeerPreviewDigest(sessionId, machine, timeoutMs = PEE
582
582
  * `opts.sessionId` (with `tty`) prints the session id and resume command when
583
583
  * the SSH hop ends, so OpenSSH's `Shared connection … closed.` is not the last
584
584
  * thing on the local shell (RUSH-3227). Omit it for one-shot non-TTY renders.
585
+ *
586
+ * Resolves `'unreachable'` when the SSH connection itself failed — ssh could not
587
+ * launch (spawn error) or exited with {@link SSH_CONN_FAILURE_CODE} (255, its
588
+ * connect-failure convention) — as opposed to `'ok'` for a hop that actually
589
+ * reached the peer (whatever the remote command's own exit code). This lets a
590
+ * caller prefer the recorded device yet fall back locally when it is genuinely
591
+ * offline (PHNX-3626); callers that ignore `'unreachable'` behave exactly as
592
+ * before (it was `'ok'`).
593
+ */
594
+ /**
595
+ * Classify a finished SSH hop by its exit code: `'unreachable'` when the
596
+ * connection itself failed (ssh's {@link SSH_CONN_FAILURE_CODE} = 255, or a null
597
+ * code from a killed/never-launched child), else `'ok'` — the remote command ran,
598
+ * whatever its own exit status. Pure so the offline-device fallback is unit-tested
599
+ * without a live SSH hop (PHNX-3626).
585
600
  */
601
+ export function peerHopOutcome(code) {
602
+ return code === SSH_CONN_FAILURE_CODE || code === null ? 'unreachable' : 'ok';
603
+ }
586
604
  export function peerHopCloseNotice(opts, machine, code) {
587
605
  if (!opts.tty || !opts.sessionId)
588
606
  return undefined;
@@ -596,9 +614,13 @@ export async function runOnPeer(args, machine, opts = {}) {
596
614
  const cols = terminalWidth();
597
615
  const env = { ...(cols > 0 ? { COLUMNS: String(cols) } : {}), ...opts.env };
598
616
  const assignments = Object.entries(env).map(([k, v]) => `${k}=${shellQuote(v)}`);
617
+ const invocation = assignments.concat(['agents', ...args].map(shellQuote)).join(' ');
618
+ // OpenSSH uses 255 for transport failure, but also propagates a reached remote
619
+ // program's 255 verbatim. Remap only the remote program's 255 inside the peer
620
+ // shell so the outer SSH status remains an unambiguous connectivity signal.
599
621
  const remoteCmd = remoteShellFor(peer.os) === 'powershell'
600
- ? buildWindowsAgentsCommand({ args, env: assignments.length ? env : undefined })
601
- : `bash -lc ${shellQuote(assignments.concat(['agents', ...args].map(shellQuote)).join(' '))}`;
622
+ ? buildWindowsAgentsCommand({ args, env: assignments.length ? env : undefined, remapExit255: true })
623
+ : `bash -lc ${shellQuote(`${invocation}; agents_rc=$?; if [ "$agents_rc" -eq 255 ]; then exit 254; fi; exit "$agents_rc"`)}`;
602
624
  const sshArgs = [...SSH_OPTS, ...controlOpts()];
603
625
  if (opts.tty)
604
626
  sshArgs.push('-tt'); // force a PTY so the resumed agent is interactive
@@ -610,7 +632,7 @@ export async function runOnPeer(args, machine, opts = {}) {
610
632
  // we resolve once it settles so the picker flow completes.
611
633
  child.on('error', (err) => {
612
634
  process.stderr.write(chalk.red(`Failed to reach ${machine}: ${err?.message ?? 'ssh failed to launch'}\n`));
613
- resolve('ok');
635
+ resolve('unreachable');
614
636
  });
615
637
  child.on('close', (code) => {
616
638
  // Interactive TTY hop: OpenSSH prints "Shared connection … closed." and
@@ -618,7 +640,10 @@ export async function runOnPeer(args, machine, opts = {}) {
618
640
  const notice = peerHopCloseNotice(opts, machine, code);
619
641
  if (notice)
620
642
  process.stderr.write(notice);
621
- resolve('ok');
643
+ // ssh exits 255 only when the connection itself failed (host down/asleep),
644
+ // distinct from the remote command's own non-zero exit. Report that so a
645
+ // caller can fall back locally instead of silently completing.
646
+ resolve(peerHopOutcome(code));
622
647
  });
623
648
  });
624
649
  }
@@ -1,7 +1,9 @@
1
1
  import { type ActiveSession } from '../active.js';
2
+ import type { SessionMeta } from '../types.js';
2
3
  import { readActiveSessionsCache } from '../session-cache.js';
3
4
  export declare const SESSION_WATCH_VERSION: 1;
4
5
  export declare const SESSION_WATCH_HEARTBEAT_MS = 15000;
6
+ export declare const SESSION_WATCH_PREVIOUS_LIMIT = 50;
5
7
  export type SessionWatchScopeStatus = 'available' | 'unavailable';
6
8
  export type SessionWatchEnvelope = {
7
9
  version: 1;
@@ -45,9 +47,15 @@ export type SessionWatchEnvelope = {
45
47
  scope: string;
46
48
  capturedAt: number;
47
49
  };
48
- export interface SessionWatchRow extends Omit<ActiveSession, 'viewingIn'> {
50
+ export interface SessionWatchRow extends Omit<ActiveSession, 'viewingIn' | 'context'> {
51
+ context: ActiveSession['context'] | 'recent';
52
+ /** Flat durable branch for history rows that are not currently in a worktree. */
53
+ branch?: string;
49
54
  rowKey: string;
50
55
  sourceDevice: string;
56
+ /** Durable index rows are kept on the stream under a distinct identity so a
57
+ * live row can replace/disappear without erasing its recoverable history. */
58
+ previous: boolean;
51
59
  resumable: boolean;
52
60
  unwatched: boolean;
53
61
  viewingIn: string | null;
@@ -60,6 +68,13 @@ export interface SessionWatchRow extends Omit<ActiveSession, 'viewingIn'> {
60
68
  /** Stable, opaque identity for one row within one device scope. */
61
69
  export declare function sessionWatchRowKey(scope: string, row: ActiveSession): string;
62
70
  export declare function toSessionWatchRow(scope: string, row: ActiveSession): SessionWatchRow;
71
+ /** Stable identity for a durable Previous row. It is deliberately distinct
72
+ * from the live row key for the same session id: both may coexist on the one
73
+ * stream, and the presentation layer lets the live row win while it exists. */
74
+ export declare function previousSessionWatchRowKey(scope: string, sessionId: string): string;
75
+ /** Project one durable indexed session into the same canonical watch contract
76
+ * as live sessions. This is the only history backfill consumed by AGI EXT. */
77
+ export declare function toPreviousSessionWatchRow(scope: string, session: SessionMeta): SessionWatchRow;
63
78
  /** Stream-local sequencer and convergent row diff. */
64
79
  export declare class SessionWatchState {
65
80
  readonly streamId: string;
@@ -67,7 +82,10 @@ export declare class SessionWatchState {
67
82
  private readonly rows;
68
83
  constructor(streamId?: string);
69
84
  private base;
70
- reset(scope: string, sourceRows: ActiveSession[]): SessionWatchEnvelope;
85
+ /** Keep one visible Previous row per session and the newest bounded window.
86
+ * Returns removed keys so delta callers can converge already-connected clients. */
87
+ private prunePrevious;
88
+ reset(scope: string, sourceRows: ActiveSession[], indexedRows?: SessionMeta[]): SessionWatchEnvelope;
71
89
  update(scope: string, sourceRows: ActiveSession[]): SessionWatchEnvelope[];
72
90
  patch(scope: string, upserts: ActiveSession[], removes: string[]): SessionWatchEnvelope[];
73
91
  scope(scope: string, status: SessionWatchScopeStatus, reason?: string): SessionWatchEnvelope;
@@ -80,9 +98,14 @@ export interface WatchLocalOptions {
80
98
  refreshMs?: number;
81
99
  heartbeatMs?: number;
82
100
  readCache?: typeof readActiveSessionsCache;
101
+ readPrevious?: (scope: string) => SessionMeta[];
83
102
  journalPath?: string;
84
103
  journalPollMs?: number;
85
104
  }
105
+ /** One bounded index read when the watch starts. The stream then owns this
106
+ * projection: active rows update through the journal, and removals demote into
107
+ * Previous rows without another transcript/index poll. */
108
+ export declare function readPreviousSessionsForWatch(scope: string): SessionMeta[];
86
109
  /**
87
110
  * Keep one local subscription alive. Startup reads one canonical reset snapshot;
88
111
  * steady state tails the canonical writer journal and never invokes a gather.
@@ -9,9 +9,13 @@ import { machineId, normalizeHost } from '../../machine-id.js';
9
9
  import { SSH_OPTS, controlOpts, shellQuote } from '../../ssh-exec.js';
10
10
  import { buildWindowsAgentsCommand, remoteShellFor } from '../../hosts/remote-cmd.js';
11
11
  import { isReapableOrphan } from '../active.js';
12
+ import { querySessions } from '../db.js';
13
+ import { linearIssueUrl } from '../linear.js';
14
+ import { sessionAgentSupportsResume } from '../recovery.js';
12
15
  import { activeSessionsJournalPath, activeSessionJournalIdentity, readActiveSessionsCache, noteActiveSessionsJournalReader, } from '../session-cache.js';
13
16
  export const SESSION_WATCH_VERSION = 1;
14
17
  export const SESSION_WATCH_HEARTBEAT_MS = 15_000;
18
+ export const SESSION_WATCH_PREVIOUS_LIMIT = 50;
15
19
  /** Stable, opaque identity for one row within one device scope. */
16
20
  export function sessionWatchRowKey(scope, row) {
17
21
  const identity = activeSessionJournalIdentity(row);
@@ -24,6 +28,9 @@ export function toSessionWatchRow(scope, row) {
24
28
  // leaked --device tunnel sessions (RUSH-3011 / issue #3b). A live or
25
29
  // recently-exited session stays resumable.
26
30
  const resumable = Boolean(row.sessionId) && !isReapableOrphan(row);
31
+ const previous = row.status === 'closed'
32
+ || row.status === 'crashed'
33
+ || (row.status === 'abandoned' && row.pidAlive === false);
27
34
  const viewingIn = row.viewingIn
28
35
  ? [row.viewingIn.app, row.viewingIn.tab ? `tab ${row.viewingIn.tab}` : undefined].filter(Boolean).join(' ')
29
36
  : null;
@@ -31,6 +38,7 @@ export function toSessionWatchRow(scope, row) {
31
38
  ...row,
32
39
  rowKey,
33
40
  sourceDevice: scope,
41
+ previous,
34
42
  resumable,
35
43
  unwatched: !viewingIn,
36
44
  viewingIn,
@@ -39,6 +47,88 @@ export function toSessionWatchRow(scope, row) {
39
47
  : null,
40
48
  };
41
49
  }
50
+ function epochMs(value) {
51
+ const parsed = value ? Date.parse(value) : Number.NaN;
52
+ return Number.isFinite(parsed) ? parsed : 0;
53
+ }
54
+ /** Stable identity for a durable Previous row. It is deliberately distinct
55
+ * from the live row key for the same session id: both may coexist on the one
56
+ * stream, and the presentation layer lets the live row win while it exists. */
57
+ export function previousSessionWatchRowKey(scope, sessionId) {
58
+ return createHash('sha256').update(`${scope}\0previous\0${sessionId}`).digest('base64url').slice(0, 22);
59
+ }
60
+ /** Project one durable indexed session into the same canonical watch contract
61
+ * as live sessions. This is the only history backfill consumed by AGI EXT. */
62
+ export function toPreviousSessionWatchRow(scope, session) {
63
+ const resumable = sessionAgentSupportsResume(session.agent);
64
+ const sourceDevice = normalizeHost(session.machine ?? scope);
65
+ const startedAtMs = epochMs(session.timestamp);
66
+ const lastActivityMs = epochMs(session.lastActivity) || startedAtMs;
67
+ const worktree = session.worktreeSlug && session.cwd
68
+ ? { slug: session.worktreeSlug, path: session.cwd, ...(session.gitBranch ? { branch: session.gitBranch } : {}) }
69
+ : undefined;
70
+ return {
71
+ context: 'recent',
72
+ kind: session.agent,
73
+ ...(session.harness ? { harness: session.harness } : {}),
74
+ sessionId: session.id,
75
+ ...(session.cwd ? { cwd: session.cwd } : {}),
76
+ ...(session.project ? { project: session.project } : {}),
77
+ ...(session.label ? { label: session.label, title: session.label } : session.topic ? { title: session.topic } : {}),
78
+ ...(session.topic ? { topic: session.topic } : {}),
79
+ ...(session.firstUserMessage ? { firstUserMessage: session.firstUserMessage } : {}),
80
+ ...(session.version ? { version: session.version } : {}),
81
+ ...(session.account ? { account: session.account } : {}),
82
+ ...(session.prUrl ? { pr: { url: session.prUrl, number: session.prNumber } } : {}),
83
+ ...(worktree ? { worktree } : {}),
84
+ ...(session.gitBranch ? { branch: session.gitBranch } : {}),
85
+ ...(session.ticketId ? { ticket: { id: session.ticketId, url: linearIssueUrl(session.ticketId) } } : {}),
86
+ ...(session.createdTickets ? { createdTickets: session.createdTickets } : {}),
87
+ ...(session.spawnedTeam ? { spawnedTeam: session.spawnedTeam } : {}),
88
+ ...(session.tokenCount != null ? { tokenCount: session.tokenCount } : {}),
89
+ ...(session.durationMs != null ? { durationMs: session.durationMs } : {}),
90
+ ...(session.subAgentCount != null ? { subAgentCount: session.subAgentCount } : {}),
91
+ startedAtMs,
92
+ lastActivityMs,
93
+ status: 'closed',
94
+ phase: 'done',
95
+ pidAlive: false,
96
+ rowKey: previousSessionWatchRowKey(scope, session.id),
97
+ sourceDevice,
98
+ previous: true,
99
+ resumable,
100
+ unwatched: true,
101
+ viewingIn: null,
102
+ recovery: resumable
103
+ ? { command: 'agents', args: ['sessions', 'resume', session.id, '--device', sourceDevice], ...(session.cwd ? { cwd: session.cwd } : {}) }
104
+ : null,
105
+ };
106
+ }
107
+ function demoteWatchRow(scope, row) {
108
+ if (!row.sessionId || row.rowKey === previousSessionWatchRowKey(scope, row.sessionId))
109
+ return null;
110
+ const sourceDevice = row.sourceDevice || scope;
111
+ return {
112
+ ...row,
113
+ context: 'recent',
114
+ status: 'closed',
115
+ phase: 'done',
116
+ pidAlive: false,
117
+ rowKey: previousSessionWatchRowKey(scope, row.sessionId),
118
+ previous: true,
119
+ unwatched: true,
120
+ viewingIn: null,
121
+ recovery: row.resumable
122
+ ? { command: 'agents', args: ['sessions', 'resume', row.sessionId, '--device', sourceDevice], ...(row.cwd ? { cwd: row.cwd } : {}) }
123
+ : null,
124
+ };
125
+ }
126
+ function isDurablePreviousRow(scope, row) {
127
+ return Boolean(row.sessionId) && row.rowKey === previousSessionWatchRowKey(scope, row.sessionId);
128
+ }
129
+ function rowRecency(row) {
130
+ return row.lastActivityMs ?? row.startedAtMs ?? 0;
131
+ }
42
132
  function sameRow(a, b) {
43
133
  return JSON.stringify(a) === JSON.stringify(b);
44
134
  }
@@ -51,24 +141,77 @@ export class SessionWatchState {
51
141
  base(type) {
52
142
  return { version: SESSION_WATCH_VERSION, type, streamId: this.streamId, sequence: ++this.sequence, capturedAt: Date.now() };
53
143
  }
54
- reset(scope, sourceRows) {
55
- const rows = sourceRows.map((row) => toSessionWatchRow(scope, row));
56
- this.rows.set(scope, new Map(rows.map((row) => [row.rowKey, row])));
57
- return { ...this.base('reset'), scope, rows };
144
+ /** Keep one visible Previous row per session and the newest bounded window.
145
+ * Returns removed keys so delta callers can converge already-connected clients. */
146
+ prunePrevious(scope, rows) {
147
+ const removed = [];
148
+ const bySession = new Map();
149
+ for (const row of rows.values()) {
150
+ if (!row.previous || !row.sessionId)
151
+ continue;
152
+ const existing = bySession.get(row.sessionId);
153
+ if (!existing) {
154
+ bySession.set(row.sessionId, row);
155
+ continue;
156
+ }
157
+ const keepRow = rowRecency(row) > rowRecency(existing)
158
+ || (rowRecency(row) === rowRecency(existing) && isDurablePreviousRow(scope, row));
159
+ const drop = keepRow ? existing : row;
160
+ const keep = keepRow ? row : existing;
161
+ if (rows.delete(drop.rowKey))
162
+ removed.push(drop.rowKey);
163
+ bySession.set(row.sessionId, keep);
164
+ }
165
+ const previous = [...bySession.values()].sort((a, b) => rowRecency(b) - rowRecency(a));
166
+ for (const row of previous.slice(SESSION_WATCH_PREVIOUS_LIMIT)) {
167
+ if (rows.delete(row.rowKey))
168
+ removed.push(row.rowKey);
169
+ }
170
+ return removed;
171
+ }
172
+ reset(scope, sourceRows, indexedRows = []) {
173
+ const liveRows = sourceRows.map((row) => toSessionWatchRow(scope, row));
174
+ const sourceIds = new Set(liveRows.map((row) => row.sessionId).filter((id) => Boolean(id)));
175
+ const rows = new Map(liveRows.map((row) => [row.rowKey, row]));
176
+ for (const indexed of indexedRows) {
177
+ if (sourceIds.has(indexed.id))
178
+ continue;
179
+ const row = toPreviousSessionWatchRow(scope, indexed);
180
+ rows.set(row.rowKey, row);
181
+ }
182
+ this.prunePrevious(scope, rows);
183
+ this.rows.set(scope, rows);
184
+ return { ...this.base('reset'), scope, rows: [...rows.values()] };
58
185
  }
59
186
  update(scope, sourceRows) {
60
187
  const previous = this.rows.get(scope) ?? new Map();
61
188
  const nextRows = sourceRows.map((row) => toSessionWatchRow(scope, row));
62
- const next = new Map(nextRows.map((row) => [row.rowKey, row]));
189
+ const activeIds = new Set(nextRows.filter((row) => !row.previous).map((row) => row.sessionId).filter((id) => Boolean(id)));
190
+ const next = new Map([...previous.values()]
191
+ .filter((row) => isDurablePreviousRow(scope, row) && (!row.sessionId || !activeIds.has(row.sessionId)))
192
+ .map((row) => [row.rowKey, row]));
193
+ for (const row of nextRows)
194
+ next.set(row.rowKey, row);
63
195
  const events = [];
64
196
  for (const [rowKey, row] of next) {
65
197
  if (!previous.has(rowKey) || !sameRow(previous.get(rowKey), row)) {
66
198
  events.push({ ...this.base('upsert'), scope, rowKey, row });
67
199
  }
68
200
  }
69
- for (const rowKey of previous.keys()) {
70
- if (!next.has(rowKey))
71
- events.push({ ...this.base('remove'), scope, rowKey });
201
+ for (const [rowKey, row] of previous) {
202
+ if (next.has(rowKey))
203
+ continue;
204
+ if (isDurablePreviousRow(scope, row) && (!row.sessionId || !activeIds.has(row.sessionId)))
205
+ continue;
206
+ events.push({ ...this.base('remove'), scope, rowKey });
207
+ const demoted = demoteWatchRow(scope, row);
208
+ if (demoted && (!next.has(demoted.rowKey) || !sameRow(next.get(demoted.rowKey), demoted))) {
209
+ next.set(demoted.rowKey, demoted);
210
+ events.push({ ...this.base('upsert'), scope, rowKey: demoted.rowKey, row: demoted });
211
+ }
212
+ }
213
+ for (const rowKey of this.prunePrevious(scope, next)) {
214
+ events.push({ ...this.base('remove'), scope, rowKey });
72
215
  }
73
216
  this.rows.set(scope, next);
74
217
  return events;
@@ -78,6 +221,11 @@ export class SessionWatchState {
78
221
  const events = [];
79
222
  for (const source of upserts) {
80
223
  const row = toSessionWatchRow(scope, source);
224
+ if (!row.previous && row.sessionId) {
225
+ const previousKey = previousSessionWatchRowKey(scope, row.sessionId);
226
+ if (current.delete(previousKey))
227
+ events.push({ ...this.base('remove'), scope, rowKey: previousKey });
228
+ }
81
229
  if (!current.has(row.rowKey) || !sameRow(current.get(row.rowKey), row)) {
82
230
  current.set(row.rowKey, row);
83
231
  events.push({ ...this.base('upsert'), scope, rowKey: row.rowKey, row });
@@ -85,8 +233,18 @@ export class SessionWatchState {
85
233
  }
86
234
  for (const identity of removes) {
87
235
  const rowKey = createHash('sha256').update(`${scope}\0${identity}`).digest('base64url').slice(0, 22);
88
- if (current.delete(rowKey))
89
- events.push({ ...this.base('remove'), scope, rowKey });
236
+ const removed = current.get(rowKey);
237
+ if (!current.delete(rowKey))
238
+ continue;
239
+ events.push({ ...this.base('remove'), scope, rowKey });
240
+ const demoted = removed ? demoteWatchRow(scope, removed) : null;
241
+ if (demoted && (!current.has(demoted.rowKey) || !sameRow(current.get(demoted.rowKey), demoted))) {
242
+ current.set(demoted.rowKey, demoted);
243
+ events.push({ ...this.base('upsert'), scope, rowKey: demoted.rowKey, row: demoted });
244
+ }
245
+ }
246
+ for (const rowKey of this.prunePrevious(scope, current)) {
247
+ events.push({ ...this.base('remove'), scope, rowKey });
90
248
  }
91
249
  this.rows.set(scope, current);
92
250
  return events;
@@ -100,6 +258,21 @@ export class SessionWatchState {
100
258
  return { ...this.base('heartbeat'), scope, capturedAt };
101
259
  }
102
260
  }
261
+ /** One bounded index read when the watch starts. The stream then owns this
262
+ * projection: active rows update through the journal, and removals demote into
263
+ * Previous rows without another transcript/index poll. */
264
+ export function readPreviousSessionsForWatch(scope) {
265
+ try {
266
+ return querySessions({
267
+ machine: normalizeHost(scope),
268
+ limit: SESSION_WATCH_PREVIOUS_LIMIT,
269
+ excludeTeamOrigin: true,
270
+ });
271
+ }
272
+ catch {
273
+ return [];
274
+ }
275
+ }
103
276
  /**
104
277
  * Keep one local subscription alive. Startup reads one canonical reset snapshot;
105
278
  * steady state tails the canonical writer journal and never invokes a gather.
@@ -107,6 +280,7 @@ export class SessionWatchState {
107
280
  export async function watchLocalSessions(options) {
108
281
  const state = new SessionWatchState();
109
282
  const readCache = options.readCache ?? readActiveSessionsCache;
283
+ const readPrevious = options.readPrevious ?? readPreviousSessionsForWatch;
110
284
  const journal = options.journalPath ?? activeSessionsJournalPath();
111
285
  const heartbeatMs = options.heartbeatMs ?? SESSION_WATCH_HEARTBEAT_MS;
112
286
  let offset = 0;
@@ -115,7 +289,7 @@ export async function watchLocalSessions(options) {
115
289
  }
116
290
  catch { /* first publication */ }
117
291
  const initial = readCache('local');
118
- options.emit(state.reset(options.scope, initial?.sessions ?? []));
292
+ options.emit(state.reset(options.scope, initial?.sessions ?? [], readPrevious(options.scope)));
119
293
  // Known gap (out of scope for RUSH-2484): a present cache alone marks
120
294
  // 'available' with no staleness/age check, so a reconnect can briefly render
121
295
  // a stale snapshot as live before the out-of-band gather this file triggers
@@ -32,6 +32,7 @@ export declare function activeSessionJournalIdentity(row: ActiveSession): string
32
32
  */
33
33
  export interface ImmutableSessionFields {
34
34
  topic?: string;
35
+ firstUserMessage?: string;
35
36
  label?: string;
36
37
  name?: string;
37
38
  cwd?: string;
@@ -51,7 +52,7 @@ export interface ImmutableSessionFields {
51
52
  context?: ActiveSession['context'];
52
53
  }
53
54
  /** Keys stored in the immutable memo (transcript-stable). */
54
- export declare const IMMUTABLE_FIELD_KEYS: readonly ["topic", "label", "name", "cwd", "project", "attachments", "startedAtMs", "version", "pr", "worktree", "ticket", "createdTickets", "spawnedTeam", "sessionFile", "owner", "assignedTask", "kind", "context"];
55
+ export declare const IMMUTABLE_FIELD_KEYS: readonly ["topic", "firstUserMessage", "label", "name", "cwd", "project", "attachments", "startedAtMs", "version", "pr", "worktree", "ticket", "createdTickets", "spawnedTeam", "sessionFile", "owner", "assignedTask", "kind", "context"];
55
56
  /**
56
57
  * Live / volatile fields that MUST NOT be served from the immutable memo.
57
58
  * They either change without a transcript write (pid death, attach state) or
@@ -51,6 +51,7 @@ const activeSnapshotMemory = createMemoryCache({
51
51
  /** Keys stored in the immutable memo (transcript-stable). */
52
52
  export const IMMUTABLE_FIELD_KEYS = [
53
53
  'topic',
54
+ 'firstUserMessage',
54
55
  'label',
55
56
  'name',
56
57
  'cwd',
@@ -232,6 +232,8 @@ export interface SessionMeta {
232
232
  /** Effective normalized launch mode captured by the SessionStart hook. */
233
233
  mode?: SessionRunMode;
234
234
  topic?: string;
235
+ /** Full, cleaned first genuine user turn; distinct from the one-line topic. */
236
+ firstUserMessage?: string;
235
237
  /**
236
238
  * The session's human-readable name — one field, several sources with a plain
237
239
  * priority: an agent-generated title / Claude `/rename` wins; else the launch
@@ -24,9 +24,12 @@ export interface PublishOptions {
24
24
  /**
25
25
  * Server-enforced visibility (RUSH-3135). `public` (default) is listed in
26
26
  * the gallery; `unlisted` is a capability URL (GET still 200, X-Robots-Tag:
27
- * noindex, hidden from gallery/list). `me` requires a Phoenix session and is
28
- * visible only to the signed-in owner; `org` requires the same and is visible
29
- * to members of the same Phoenix organization.
27
+ * noindex, hidden from gallery/list — obscurity, NOT authentication);
28
+ * `private` is token-gated (the Worker serves it only to a request carrying
29
+ * the matching viewer key, else 404 — the real read-auth fix, PHNX-3654);
30
+ * `me` requires a Phoenix session and is visible only to the signed-in owner;
31
+ * `org` requires the same and is visible to members of the same Phoenix
32
+ * organization.
30
33
  */
31
34
  visibility?: ShareVisibility;
32
35
  /**
@@ -37,6 +40,14 @@ export interface PublishOptions {
37
40
  * not break.
38
41
  */
39
42
  unlisted?: boolean;
43
+ /**
44
+ * Token-gate this page (`--protected` → `visibility=private`, PHNX-3654). The
45
+ * CLI mints a random viewer token, the Worker stores only its SHA-256 hash,
46
+ * and the published URL carries `?k=<token>`; a request without a matching
47
+ * `?k=` / `Authorization: Bearer` gets a `404`. Unlike `unlisted` (obscurity),
48
+ * this is enforced read-auth. Alias of `--visibility private`.
49
+ */
50
+ protected?: boolean;
40
51
  /**
41
52
  * Bypass the pre-publish sensitive-content scan (emails / credential-shaped
42
53
  * strings). Required when the page intentionally carries those patterns.
@@ -91,9 +102,17 @@ export interface PublishOptions {
91
102
  /** DI seam for tests — override provenance auto-capture (agent/session/host/repo/date). */
92
103
  provenance?: ShareProvenance;
93
104
  }
94
- export type ShareVisibility = 'public' | 'unlisted' | 'me' | 'org';
95
- /** The visibility levels a share may carry — the Worker's own set, used to
96
- * validate `--visibility` and `share visibility <target> <level>`. */
105
+ export type ShareVisibility = 'public' | 'unlisted' | 'private' | 'me' | 'org';
106
+ /** The visibility levels a publish `--visibility` may select — the Worker's own
107
+ * set. `private` (token-gated, PHNX-3654) is settable only at publish time,
108
+ * since it mints a viewer token the metadata-edit route can't carry, so it is
109
+ * NOT in {@link SHARE_VISIBILITY_LEVELS} (the in-place `share visibility <level>`
110
+ * set). */
111
+ export declare const PUBLISH_VISIBILITY_LEVELS: readonly ShareVisibility[];
112
+ /** The visibility levels an ALREADY-published share may be re-scoped to in place
113
+ * — the set `share visibility <target> <level>` and the inline owner control
114
+ * accept. Excludes `private`: re-scoping to token-gated needs a fresh viewer
115
+ * token, which only the publish path mints. */
97
116
  export declare const SHARE_VISIBILITY_LEVELS: readonly ShareVisibility[];
98
117
  export interface PublishResult {
99
118
  url: string;
@@ -105,19 +124,46 @@ export interface PublishResult {
105
124
  visibility?: ShareVisibility;
106
125
  /** True when the page was published with `visibility=unlisted`. */
107
126
  unlisted?: boolean;
127
+ /** The raw viewer token minted for a `private` (token-gated) publish, or
128
+ * undefined for any other visibility. It rides in {@link url} as `?k=<token>`;
129
+ * only its hash is stored server-side. Treat as a secret. */
130
+ viewerToken?: string;
108
131
  /** The label stored with this share — explicit (`--label`) or derived. */
109
132
  label?: string;
110
133
  /** Whether `label` came from `--label` or was auto-derived. */
111
134
  labelSource?: 'explicit' | 'derived';
112
135
  }
113
136
  /**
114
- * `--unlisted` / `{ unlisted: true }` map to `unlisted`; `--visibility me|org`
115
- * passes through; otherwise `visibility` (default public).
137
+ * `--protected` / `{ protected: true }` map to `private` (token-gated — it wins
138
+ * over `--unlisted` when both are set, being the stronger control); `--unlisted`
139
+ * maps to `unlisted`; `--visibility private|unlisted|me|org` passes through;
140
+ * otherwise `visibility` (default public).
116
141
  */
117
142
  export declare function resolveShareVisibility(opts?: {
118
143
  visibility?: ShareVisibility;
119
144
  unlisted?: boolean;
145
+ protected?: boolean;
120
146
  }): ShareVisibility;
147
+ /** Mint a fresh random viewer token for a `private` (token-gated) publish. The
148
+ * raw token rides ONLY in the returned URL's `?k=`; the Worker stores just its
149
+ * SHA-256 hash, so the object metadata never carries the secret. */
150
+ export declare function generateViewerToken(): string;
151
+ /** SHA-256 hex of a viewer token — the form the Worker also computes and stores,
152
+ * so a CLI-side preview of the stored hash matches. Exported for tests. */
153
+ export declare function hashViewerToken(token: string): string;
154
+ /** A 64-bit random slug tail (16 lowercase hex chars) for a capability-URL
155
+ * publish, so the slug can never be derived/guessed from the title (PHNX-3654).
156
+ * `unlisted` leans on this for obscurity; `private` uses it as defense-in-depth
157
+ * behind the viewer token. */
158
+ export declare function randomSlugTail(): string;
159
+ /**
160
+ * The loud stderr warning printed on an `unlisted` / `--private` publish
161
+ * (PHNX-3654): `unlisted` is obscurity, NOT read-authentication — anyone with
162
+ * the URL can read it. Points the user at the real controls (`--protected`,
163
+ * `--expire`, `me`/`org`). Lives here beside the visibility logic; `share.ts`
164
+ * prints it so the lib layer stays free of `console.*`.
165
+ */
166
+ export declare function unlistedNotPrivateWarning(): string;
121
167
  export interface ShareProvenance {
122
168
  /** Harness/agent name (`AGENTS_AGENT_NAME`), when publishing from an agent run. */
123
169
  agent?: string;
@@ -136,7 +182,7 @@ export interface ShareProvenance {
136
182
  * provenance the CLI sets automatically, plus `expires-at` / `visibility` /
137
183
  * `owner` which the Worker stamps itself.
138
184
  */
139
- export declare const RESERVED_META_KEYS: readonly ["expires-at", "published-at", "visibility", "owner", "org_domain", "agent", "session", "host", "repo", "date", "avatar", "label", "label-source", "og-title", "og-description", "og-generated", "og-source-etag"];
185
+ export declare const RESERVED_META_KEYS: readonly ["expires-at", "published-at", "visibility", "viewer-token-hash", "owner", "org_domain", "agent", "session", "host", "repo", "date", "avatar", "label", "label-source", "og-title", "og-description", "og-generated", "og-source-etag"];
140
186
  /**
141
187
  * Auto-capture publish provenance from the exec env, git, and the local clock.
142
188
  * Every field is present only when the environment genuinely carries it — a