@phnx-labs/agents-cli 1.22.66 → 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.
- package/CHANGELOG.md +26 -0
- package/README.md +7 -2
- package/dist/bootstrap.js +3 -3
- package/dist/commands/browser.js +46 -21
- package/dist/commands/daemon-test-harness.d.ts +1 -1
- package/dist/commands/daemon-test-harness.js +2 -2
- package/dist/commands/daemon.js +21 -22
- package/dist/commands/exec.js +40 -0
- package/dist/commands/monitors.js +2 -2
- package/dist/commands/repo.js +57 -19
- package/dist/commands/resume.d.ts +16 -0
- package/dist/commands/resume.js +41 -8
- package/dist/commands/routines.js +42 -21
- package/dist/commands/sessions-optimize.js +1 -1
- package/dist/commands/sessions.js +4 -11
- package/dist/commands/share.d.ts +5 -3
- package/dist/commands/share.js +33 -9
- package/dist/lib/accounting/account-pool-collect.d.ts +6 -4
- package/dist/lib/accounting/account-pool-collect.js +6 -4
- package/dist/lib/accounting/usage-ingest.js +4 -2
- package/dist/lib/accounting/usage-sync.d.ts +38 -93
- package/dist/lib/accounting/usage-sync.js +66 -210
- package/dist/lib/accounting/usage.d.ts +11 -3
- package/dist/lib/accounting/usage.js +23 -10
- package/dist/lib/auth-health.d.ts +8 -0
- package/dist/lib/auth-health.js +4 -4
- package/dist/lib/browser/caller-identity.d.ts +12 -0
- package/dist/lib/browser/caller-identity.js +19 -0
- package/dist/lib/browser/ipc.d.ts +37 -32
- package/dist/lib/browser/ipc.js +146 -94
- package/dist/lib/browser/task-index.d.ts +10 -2
- package/dist/lib/browser/task-index.js +22 -3
- package/dist/lib/daemon/account-state-daemon-service.d.ts +49 -9
- package/dist/lib/daemon/account-state-daemon-service.js +81 -18
- package/dist/lib/daemon/auth-sync-service.d.ts +4 -4
- package/dist/lib/daemon/auth-sync-service.js +17 -6
- package/dist/lib/daemon/catchup-service.d.ts +51 -0
- package/dist/lib/daemon/catchup-service.js +51 -0
- package/dist/lib/daemon/daemon.d.ts +12 -22
- package/dist/lib/daemon/daemon.js +463 -176
- package/dist/lib/daemon/runner.js +2 -0
- package/dist/lib/daemon/service.d.ts +22 -4
- package/dist/lib/daemon/service.js +2 -2
- package/dist/lib/daemon/supervisor.d.ts +55 -15
- package/dist/lib/daemon/supervisor.js +119 -29
- package/dist/lib/daemon/usage-sync-service.d.ts +4 -6
- package/dist/lib/daemon/usage-sync-service.js +22 -18
- package/dist/lib/daemon-health.js +36 -31
- package/dist/lib/daemon-services.d.ts +1 -1
- package/dist/lib/daemon-services.js +12 -2
- package/dist/lib/daemon-ticks.d.ts +9 -6
- package/dist/lib/daemon-ticks.js +14 -8
- package/dist/lib/devices/registry.js +2 -0
- package/dist/lib/exec.js +11 -0
- package/dist/lib/feed/watch.d.ts +3 -0
- package/dist/lib/feed/watch.js +13 -3
- package/dist/lib/fleet-shared-repo-sync.d.ts +36 -0
- package/dist/lib/fleet-shared-repo-sync.js +333 -0
- package/dist/lib/fleet-shared-state.d.ts +38 -0
- package/dist/lib/fleet-shared-state.js +105 -0
- package/dist/lib/hosts/remote-cmd.d.ts +2 -0
- package/dist/lib/hosts/remote-cmd.js +12 -3
- package/dist/lib/lock-compromise.d.ts +8 -0
- package/dist/lib/lock-compromise.js +12 -0
- package/dist/lib/monitors/engine.d.ts +2 -1
- package/dist/lib/monitors/engine.js +9 -2
- package/dist/lib/refresh-coordinator.js +2 -0
- package/dist/lib/run-trace-sync.d.ts +13 -0
- package/dist/lib/run-trace-sync.js +77 -0
- package/dist/lib/secrets/push.d.ts +10 -0
- package/dist/lib/secrets/push.js +86 -7
- package/dist/lib/secrets/remote.d.ts +18 -6
- package/dist/lib/secrets/remote.js +29 -4
- package/dist/lib/secrets/reserved-sync.d.ts +28 -27
- package/dist/lib/secrets/reserved-sync.js +119 -101
- package/dist/lib/session/active.d.ts +3 -1
- package/dist/lib/session/active.js +2 -0
- package/dist/lib/session/actor-sidecar.d.ts +12 -0
- package/dist/lib/session/actor-sidecar.js +2 -0
- package/dist/lib/session/db.d.ts +5 -2
- package/dist/lib/session/db.js +54 -22
- package/dist/lib/session/discover.d.ts +12 -3
- package/dist/lib/session/discover.js +106 -25
- package/dist/lib/session/prompt.d.ts +11 -0
- package/dist/lib/session/prompt.js +25 -0
- package/dist/lib/session/recovery.d.ts +30 -2
- package/dist/lib/session/recovery.js +56 -4
- package/dist/lib/session/remote/remote-list.d.ts +17 -1
- package/dist/lib/session/remote/remote-list.js +29 -4
- package/dist/lib/session/remote/watch.d.ts +25 -2
- package/dist/lib/session/remote/watch.js +185 -11
- package/dist/lib/session/session-cache.d.ts +2 -1
- package/dist/lib/session/session-cache.js +1 -0
- package/dist/lib/session/types.d.ts +2 -0
- package/dist/lib/share/publish.d.ts +55 -9
- package/dist/lib/share/publish.js +87 -9
- package/dist/lib/share/worker-template.js +100 -8
- package/dist/lib/ssh-exec.d.ts +2 -0
- package/dist/lib/ssh-exec.js +20 -4
- package/dist/lib/teams/registry.js +2 -0
- package/dist/lib/testdata/daemon-health-writer.d.ts +1 -0
- package/dist/lib/testdata/daemon-health-writer.js +8 -0
- package/dist/lib/traces/sync.d.ts +7 -0
- package/dist/lib/traces/sync.js +9 -0
- package/dist/lib/usage-refresh.d.ts +8 -2
- package/dist/lib/usage-refresh.js +3 -3
- package/package.json +1 -1
- package/dist/lib/account-state-service.d.ts +0 -21
- 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(
|
|
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('
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
|
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
|
|
70
|
-
if (
|
|
71
|
-
|
|
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
|
-
|
|
89
|
-
|
|
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
|
|
@@ -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
|
|
28
|
-
*
|
|
29
|
-
*
|
|
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
|
|
96
|
-
*
|
|
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
|
-
* `--
|
|
115
|
-
*
|
|
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
|