@phnx-labs/agents-cli 1.22.46 → 1.22.49
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 +663 -0
- package/LICENSE +102 -182
- package/README.md +96 -49
- package/dist/bootstrap.js +4 -2
- package/dist/cli/command-registry.d.ts +2 -1
- package/dist/cli/command-registry.js +6 -2
- package/dist/commands/accounts.d.ts +58 -0
- package/dist/commands/accounts.js +243 -25
- package/dist/commands/artifacts.js +7 -6
- package/dist/commands/attach.js +1 -1
- package/dist/commands/auth.js +7 -5
- package/dist/commands/browser-sessions-picker.js +9 -7
- package/dist/commands/browser.js +711 -62
- package/dist/commands/config.d.ts +0 -1
- package/dist/commands/config.js +26 -24
- package/dist/commands/cost.d.ts +1 -1
- package/dist/commands/daemon.d.ts +21 -0
- package/dist/commands/daemon.js +147 -12
- package/dist/commands/detach.d.ts +13 -0
- package/dist/commands/detach.js +11 -8
- package/dist/commands/exec.js +107 -53
- package/dist/commands/feed-watch.d.ts +2 -0
- package/dist/commands/feed-watch.js +28 -0
- package/dist/commands/feed.js +27 -0
- package/dist/commands/feedback.js +3 -19
- package/dist/commands/focus.d.ts +14 -0
- package/dist/commands/focus.js +48 -5
- package/dist/commands/go.d.ts +3 -0
- package/dist/commands/go.js +16 -3
- package/dist/commands/insights.d.ts +1 -1
- package/dist/commands/insights.js +2 -2
- package/dist/commands/inspect.js +2 -2
- package/dist/commands/lease.d.ts +1 -1
- package/dist/commands/lease.js +7 -6
- package/dist/commands/mailboxes.js +4 -3
- package/dist/commands/menubar.js +11 -4
- package/dist/commands/projects.d.ts +16 -1
- package/dist/commands/projects.js +58 -64
- package/dist/commands/resume.js +1 -1
- package/dist/commands/route.js +4 -4
- package/dist/commands/routines.js +4 -3
- package/dist/commands/run-broadcast.d.ts +1 -1
- package/dist/commands/run-broadcast.js +1 -1
- package/dist/commands/sessions-picker-factory.d.ts +2 -1
- package/dist/commands/sessions-picker-factory.js +1 -1
- package/dist/commands/sessions-picker.d.ts +22 -0
- package/dist/commands/sessions-picker.js +48 -9
- package/dist/commands/sessions-stop.d.ts +24 -0
- package/dist/commands/sessions-stop.js +66 -0
- package/dist/commands/sessions-trace.d.ts +82 -0
- package/dist/commands/sessions-trace.js +387 -0
- package/dist/commands/sessions.js +5 -0
- package/dist/commands/setup-secrets.js +23 -0
- package/dist/commands/share.d.ts +20 -5
- package/dist/commands/share.js +152 -52
- package/dist/commands/ssh.d.ts +15 -0
- package/dist/commands/ssh.js +243 -18
- package/dist/commands/teams.js +34 -2
- package/dist/commands/traces.d.ts +2 -0
- package/dist/commands/traces.js +184 -0
- package/dist/commands/view.d.ts +2 -0
- package/dist/commands/view.js +32 -62
- package/dist/commands/watchdog.js +9 -14
- package/dist/index.d.ts +1 -0
- package/dist/index.js +5 -0
- package/dist/lib/account-capabilities.d.ts +4 -0
- package/dist/lib/account-capabilities.js +13 -9
- package/dist/lib/account-registry.d.ts +2 -0
- package/dist/lib/account-registry.js +30 -2
- package/dist/lib/accounting/rotate.d.ts +9 -0
- package/dist/lib/accounting/rotate.js +12 -2
- package/dist/lib/accounting/usage.d.ts +84 -6
- package/dist/lib/accounting/usage.js +293 -75
- package/dist/lib/agent-modes.js +29 -1
- package/dist/lib/agent-spec/agents.d.ts +4 -3
- package/dist/lib/agent-spec/agents.js +25 -15
- package/dist/lib/analytics/mix-commands.d.ts +1 -1
- package/dist/lib/analytics/mix-commands.js +2 -2
- package/dist/lib/auth-health.d.ts +13 -3
- package/dist/lib/auth-health.js +50 -8
- package/dist/lib/browser/devices.d.ts +19 -0
- package/dist/lib/browser/devices.js +23 -0
- package/dist/lib/browser/drivers/ssh.d.ts +16 -2
- package/dist/lib/browser/drivers/ssh.js +36 -9
- package/dist/lib/browser/ipc.d.ts +1 -1
- package/dist/lib/browser/ipc.js +40 -9
- package/dist/lib/browser/profiles.d.ts +104 -65
- package/dist/lib/browser/profiles.js +277 -165
- package/dist/lib/browser/registry.d.ts +39 -0
- package/dist/lib/browser/registry.js +149 -0
- package/dist/lib/browser/remote-control.d.ts +28 -2
- package/dist/lib/browser/remote-control.js +35 -3
- package/dist/lib/browser/resolve-target.d.ts +68 -0
- package/dist/lib/browser/resolve-target.js +250 -0
- package/dist/lib/browser/runtime-state.d.ts +44 -11
- package/dist/lib/browser/runtime-state.js +98 -15
- package/dist/lib/browser/service.d.ts +65 -9
- package/dist/lib/browser/service.js +196 -61
- package/dist/lib/browser/sessions-list.d.ts +1 -3
- package/dist/lib/browser/sessions-list.js +3 -16
- package/dist/lib/browser/task-index.d.ts +70 -0
- package/dist/lib/browser/task-index.js +180 -0
- package/dist/lib/browser/types.d.ts +12 -1
- package/dist/lib/byok-usage.js +2 -2
- package/dist/lib/claude-statusline.d.ts +29 -0
- package/dist/lib/claude-statusline.js +139 -0
- package/dist/lib/cloud/host.d.ts +1 -1
- package/dist/lib/cloud/host.js +1 -1
- package/dist/lib/cloud/rush.js +1 -1
- package/dist/lib/codex-policy.d.ts +11 -1
- package/dist/lib/codex-policy.js +20 -5
- package/dist/lib/computer/computer-rpc.js +2 -2
- package/dist/lib/computer/download.d.ts +15 -15
- package/dist/lib/computer/download.js +35 -98
- package/dist/lib/computer/ssh-tunnel.d.ts +0 -3
- package/dist/lib/computer/ssh-tunnel.js +12 -22
- package/dist/lib/config-keys.d.ts +6 -11
- package/dist/lib/config-keys.js +18 -19
- package/dist/lib/crabbox/cli.d.ts +1 -1
- package/dist/lib/crabbox/cli.js +2 -2
- package/dist/lib/crabbox/lease.d.ts +9 -0
- package/dist/lib/crabbox/lease.js +70 -15
- package/dist/lib/crabbox/runtimes.js +1 -1
- package/dist/lib/daemon/account-state-daemon-service.d.ts +16 -0
- package/dist/lib/daemon/account-state-daemon-service.js +26 -0
- package/dist/lib/daemon/browser-ipc-service.d.ts +20 -0
- package/dist/lib/daemon/browser-ipc-service.js +46 -0
- package/dist/lib/daemon/daemon.d.ts +3 -0
- package/dist/lib/daemon/daemon.js +212 -444
- package/dist/lib/daemon/device-probe-service.d.ts +26 -0
- package/dist/lib/daemon/device-probe-service.js +47 -0
- package/dist/lib/daemon/keychain-reap-service.d.ts +17 -0
- package/dist/lib/daemon/keychain-reap-service.js +32 -0
- package/dist/lib/daemon/monitor-engine-service.d.ts +22 -0
- package/dist/lib/daemon/monitor-engine-service.js +29 -0
- package/dist/lib/daemon/runner.js +15 -2
- package/dist/lib/daemon/secrets-broker-service.d.ts +20 -0
- package/dist/lib/daemon/secrets-broker-service.js +66 -0
- package/dist/lib/daemon/self-heal-service.d.ts +27 -0
- package/dist/lib/daemon/self-heal-service.js +48 -0
- package/dist/lib/daemon/service.d.ts +102 -0
- package/dist/lib/daemon/service.js +98 -0
- package/dist/lib/daemon/session-index-service.d.ts +22 -0
- package/dist/lib/daemon/session-index-service.js +40 -0
- package/dist/lib/daemon/state-dir-check-service.d.ts +43 -0
- package/dist/lib/daemon/state-dir-check-service.js +63 -0
- package/dist/lib/daemon/supervisor.d.ts +87 -0
- package/dist/lib/daemon/supervisor.js +298 -0
- package/dist/lib/daemon/watchdog-service.d.ts +19 -0
- package/dist/lib/daemon/watchdog-service.js +40 -0
- package/dist/lib/daemon-health.d.ts +15 -0
- package/dist/lib/daemon-health.js +29 -5
- package/dist/lib/daemon-services.d.ts +8 -1
- package/dist/lib/daemon-services.js +55 -0
- package/dist/lib/daemon-ticks.d.ts +13 -2
- package/dist/lib/daemon-ticks.js +22 -20
- package/dist/lib/device-config.d.ts +25 -10
- package/dist/lib/device-config.js +90 -39
- package/dist/lib/devices/config-migration.d.ts +7 -0
- package/dist/lib/devices/config-migration.js +67 -3
- package/dist/lib/devices/health.d.ts +27 -2
- package/dist/lib/devices/health.js +39 -9
- package/dist/lib/devices/interactive-host.d.ts +15 -0
- package/dist/lib/devices/interactive-host.js +53 -0
- package/dist/lib/devices/registry.d.ts +61 -4
- package/dist/lib/devices/registry.js +94 -38
- package/dist/lib/devices/stats-cache.d.ts +26 -0
- package/dist/lib/devices/stats-cache.js +59 -7
- package/dist/lib/devices/worker-pick.d.ts +48 -0
- package/dist/lib/devices/worker-pick.js +113 -0
- package/dist/lib/exec.d.ts +70 -14
- package/dist/lib/exec.js +110 -38
- package/dist/lib/feed/answer.d.ts +29 -0
- package/dist/lib/feed/answer.js +145 -0
- package/dist/lib/feed/attention.d.ts +83 -0
- package/dist/lib/feed/attention.js +0 -0
- package/dist/lib/feed/feed.d.ts +109 -0
- package/dist/lib/feed/feed.js +154 -3
- package/dist/lib/feed/pr-status.d.ts +14 -0
- package/dist/lib/feed/pr-status.js +47 -0
- package/dist/lib/feed/watch.d.ts +59 -0
- package/dist/lib/feed/watch.js +180 -0
- package/dist/lib/fleet/capture.js +6 -0
- package/dist/lib/fleet/remote-login.js +8 -3
- package/dist/lib/fleet/types.d.ts +17 -0
- package/dist/lib/harness/adapter.d.ts +11 -0
- package/dist/lib/harness/adapters/claude.js +38 -24
- package/dist/lib/harness/adapters/codex.js +2 -4
- package/dist/lib/harness/adapters/cursor.js +8 -11
- package/dist/lib/helper-download.d.ts +108 -0
- package/dist/lib/helper-download.js +194 -0
- package/dist/lib/helper-versions.d.ts +52 -0
- package/dist/lib/helper-versions.js +58 -0
- package/dist/lib/hooks/cache.js +19 -0
- package/dist/lib/hooks/install.js +2 -2
- package/dist/lib/hooks/match.d.ts +3 -0
- package/dist/lib/hooks/match.js +25 -0
- package/dist/lib/hosts/credentials.d.ts +1 -1
- package/dist/lib/hosts/credentials.js +1 -1
- package/dist/lib/hosts/dispatch.d.ts +7 -1
- package/dist/lib/hosts/dispatch.js +24 -5
- package/dist/lib/hosts/passthrough.d.ts +8 -15
- package/dist/lib/hosts/passthrough.js +35 -16
- package/dist/lib/hosts/providers/devices.d.ts +1 -1
- package/dist/lib/hosts/providers/devices.js +1 -1
- package/dist/lib/hosts/reconnect.d.ts +179 -21
- package/dist/lib/hosts/reconnect.js +266 -45
- package/dist/lib/hosts/registry.js +7 -0
- package/dist/lib/installations/migrate.d.ts +4 -5
- package/dist/lib/installations/migrate.js +6 -7
- package/dist/lib/installations/shims.d.ts +1 -1
- package/dist/lib/installations/shims.js +23 -13
- package/dist/lib/installations/versions.js +25 -2
- package/dist/lib/linear-autoclose.d.ts +1 -1
- package/dist/lib/linear-autoclose.js +1 -1
- package/dist/lib/loop.d.ts +2 -2
- package/dist/lib/loop.js +1 -1
- package/dist/lib/menubar/download-menubar.d.ts +47 -0
- package/dist/lib/menubar/download-menubar.js +60 -0
- package/dist/lib/menubar/install-menubar.d.ts +44 -7
- package/dist/lib/menubar/install-menubar.js +105 -23
- package/dist/lib/menubar/notify-desktop.d.ts +3 -3
- package/dist/lib/menubar/notify-desktop.js +6 -6
- package/dist/lib/model-tiers.d.ts +1 -1
- package/dist/lib/open-url.d.ts +60 -2
- package/dist/lib/open-url.js +167 -12
- package/dist/lib/project-probe.d.ts +1 -1
- package/dist/lib/project-probe.js +1 -1
- package/dist/lib/project-pull.d.ts +1 -1
- package/dist/lib/project-pull.js +2 -2
- package/dist/lib/projects.d.ts +1 -1
- package/dist/lib/projects.js +1 -1
- package/dist/lib/refresh-coordinator.d.ts +1 -1
- package/dist/lib/refresh-coordinator.js +1 -1
- package/dist/lib/sandbox.js +6 -7
- package/dist/lib/secrets/download-keychain.d.ts +47 -0
- package/dist/lib/secrets/download-keychain.js +70 -0
- package/dist/lib/secrets/install-helper.d.ts +20 -1
- package/dist/lib/secrets/install-helper.js +44 -3
- package/dist/lib/session/active.d.ts +67 -0
- package/dist/lib/session/active.js +86 -2
- package/dist/lib/session/db.d.ts +11 -1
- package/dist/lib/session/db.js +142 -8
- package/dist/lib/session/discover.d.ts +23 -1
- package/dist/lib/session/discover.js +44 -10
- package/dist/lib/session/highlights.d.ts +49 -1
- package/dist/lib/session/highlights.js +95 -0
- package/dist/lib/session/host-link.d.ts +16 -2
- package/dist/lib/session/host-link.js +7 -1
- package/dist/lib/session/parse.js +33 -1
- package/dist/lib/session/prompt.d.ts +35 -0
- package/dist/lib/session/prompt.js +56 -0
- package/dist/lib/session/remote/remote-list.d.ts +9 -0
- package/dist/lib/session/remote/remote-list.js +19 -2
- package/dist/lib/session/remote/watch.d.ts +1 -1
- package/dist/lib/session/remote/watch.js +17 -3
- package/dist/lib/session/render.js +7 -3
- package/dist/lib/session/session-cache.d.ts +53 -0
- package/dist/lib/session/session-cache.js +93 -1
- package/dist/lib/session/state.js +1 -1
- package/dist/lib/session/tool-calls.d.ts +2 -0
- package/dist/lib/session/tool-calls.js +1 -1
- package/dist/lib/session/trajectory-compare.d.ts +47 -0
- package/dist/lib/session/trajectory-compare.js +116 -0
- package/dist/lib/session/trajectory-html.d.ts +19 -0
- package/dist/lib/session/trajectory-html.js +770 -0
- package/dist/lib/session/trajectory-lineage.d.ts +92 -0
- package/dist/lib/session/trajectory-lineage.js +195 -0
- package/dist/lib/session/trajectory-text.d.ts +37 -0
- package/dist/lib/session/trajectory-text.js +284 -0
- package/dist/lib/session/trajectory.d.ts +102 -0
- package/dist/lib/session/trajectory.js +384 -0
- package/dist/lib/session/types.d.ts +15 -0
- package/dist/lib/sha256-asset.d.ts +26 -0
- package/dist/lib/sha256-asset.js +41 -0
- package/dist/lib/share/backend.d.ts +95 -0
- package/dist/lib/share/backend.js +139 -0
- package/dist/lib/share/delete.d.ts +6 -2
- package/dist/lib/share/delete.js +8 -9
- package/dist/lib/share/html.d.ts +22 -0
- package/dist/lib/share/html.js +88 -0
- package/dist/lib/share/provision.d.ts +18 -4
- package/dist/lib/share/provision.js +38 -4
- package/dist/lib/share/publish.d.ts +39 -15
- package/dist/lib/share/publish.js +55 -26
- package/dist/lib/share/worker-template.js +171 -11
- package/dist/lib/sqlite.js +16 -3
- package/dist/lib/ssh-exec.d.ts +65 -0
- package/dist/lib/ssh-exec.js +120 -4
- package/dist/lib/startup/command-registry.d.ts +3 -1
- package/dist/lib/startup/command-registry.js +6 -3
- package/dist/lib/startup/dev-build.js +3 -3
- package/dist/lib/state.d.ts +2 -2
- package/dist/lib/state.js +11 -5
- package/dist/lib/subagents-registry.js +1 -1
- package/dist/lib/teams/agents.js +9 -0
- package/dist/lib/terminal/index.d.ts +1 -1
- package/dist/lib/terminal/inject.d.ts +19 -0
- package/dist/lib/terminal/inject.js +19 -9
- package/dist/lib/terminal/types.d.ts +1 -1
- package/dist/lib/terminal/types.js +1 -1
- package/dist/lib/tmux/orphan-reap.js +1 -1
- package/dist/lib/tmux/session.d.ts +46 -13
- package/dist/lib/tmux/session.js +204 -27
- package/dist/lib/traces/backend.d.ts +29 -0
- package/dist/lib/traces/backend.js +46 -0
- package/dist/lib/traces/classify.d.ts +52 -0
- package/dist/lib/traces/classify.js +72 -0
- package/dist/lib/traces/config.d.ts +10 -0
- package/dist/lib/traces/config.js +2 -0
- package/dist/lib/traces/provision.d.ts +19 -0
- package/dist/lib/traces/provision.js +30 -0
- package/dist/lib/traces/sync.d.ts +150 -0
- package/dist/lib/traces/sync.js +447 -0
- package/dist/lib/traces/worker-template.d.ts +4 -0
- package/dist/lib/traces/worker-template.js +159 -0
- package/dist/lib/types.d.ts +34 -19
- package/dist/lib/types.js +14 -0
- package/dist/lib/usage-backoff.d.ts +2 -1
- package/dist/lib/usage-backoff.js +60 -19
- package/dist/lib/usage-refresh.d.ts +18 -4
- package/dist/lib/usage-refresh.js +68 -19
- package/dist/lib/watchdog/log.d.ts +1 -1
- package/dist/lib/watchdog/log.js +1 -1
- package/dist/lib/watchdog/runner.d.ts +36 -45
- package/dist/lib/watchdog/runner.js +150 -119
- package/dist/lib/watchdog/service.d.ts +0 -1
- package/dist/lib/watchdog/service.js +0 -1
- package/dist/lib/watchdog/watchdog-agent.d.ts +38 -0
- package/dist/lib/watchdog/watchdog-agent.js +59 -0
- package/dist/lib/watchdog/watchdog.d.ts +11 -2
- package/dist/lib/watchdog/watchdog.js +37 -104
- package/dist/lib/workflows.d.ts +1 -1
- package/package.json +5 -7
- package/dist/commands/usage.d.ts +0 -27
- package/dist/commands/usage.js +0 -96
- package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +0 -24
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +0 -128
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +0 -24
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +0 -123
- package/dist/lib/secrets/Agents CLI.app/Contents/embedded.provisionprofile +0 -0
- package/dist/lib/usage-fleet.d.ts +0 -32
- package/dist/lib/usage-fleet.js +0 -125
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { getActiveSessions } from '../session/active.js';
|
|
3
|
+
import { resolveAnswerRoute, resumeArgv } from '../answer-router.js';
|
|
4
|
+
import { enqueue, mailboxDir } from '../mailbox.js';
|
|
5
|
+
import { injectIntoTerminal } from '../terminal/index.js';
|
|
6
|
+
import { verifyOperatorIdentity } from '../operator.js';
|
|
7
|
+
import { blockIdForSession, getAnswerRecord, publishBlock, readBlock, readResolution, recordAnswer, recordMessageReceipt, rollbackAnswerClaim, } from './feed.js';
|
|
8
|
+
import { reconcileAttention } from './attention.js';
|
|
9
|
+
import { readPullRequestStatus } from './pr-status.js';
|
|
10
|
+
import { getAgentsInvocation } from '../daemon/daemon.js';
|
|
11
|
+
function sessionForBlock(block, sessions) {
|
|
12
|
+
return sessions.find((session) => session.sessionId === block.sessionId || session.agentId === block.mailboxId);
|
|
13
|
+
}
|
|
14
|
+
function blockFromAttention(attention, session) {
|
|
15
|
+
return {
|
|
16
|
+
blockId: blockIdForSession(attention.sessionId), sessionId: attention.sessionId,
|
|
17
|
+
mailboxId: attention.mailboxId, host: attention.host, runtime: session.kind,
|
|
18
|
+
generation: attention.key.slice(attention.key.lastIndexOf('/') + 1), source: attention.source,
|
|
19
|
+
state: 'open', sourceCursor: attention.sourceCursor, project: attention.project,
|
|
20
|
+
ts: attention.openedAt, questions: [attention.question ?? { text: 'Continue from this attention item.' }],
|
|
21
|
+
kind: attention.kind === 'permission' ? 'notification' : attention.kind === 'declared' ? 'declared' : attention.source === 'system' ? 'control' : 'question',
|
|
22
|
+
safeDefault: attention.safeDefault,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
async function resolveBlock(attentionKey, sessions, root) {
|
|
26
|
+
const ownerHost = attentionKey.slice(0, attentionKey.indexOf('/'));
|
|
27
|
+
for (const session of sessions) {
|
|
28
|
+
if (!session.sessionId)
|
|
29
|
+
continue;
|
|
30
|
+
let block = readBlock(blockIdForSession(session.sessionId), root);
|
|
31
|
+
const projectedSession = { ...session, host: ownerHost };
|
|
32
|
+
const attention = reconcileAttention({ block, session: projectedSession, pullRequest: await readPullRequestStatus(session), resolution: readResolution(blockIdForSession(session.sessionId), root), nowMs: Date.now() });
|
|
33
|
+
if (attention?.key === attentionKey && block)
|
|
34
|
+
return { block, attention };
|
|
35
|
+
// The winning caller advances the block to answered before a concurrent
|
|
36
|
+
// loser resolves it. Reconstruct only this block's original generation so
|
|
37
|
+
// the loser can return already_answered without routing a second reply.
|
|
38
|
+
if (block) {
|
|
39
|
+
const original = reconcileAttention({ block: { ...block, state: 'open', answer: undefined }, session: projectedSession, nowMs: Date.now() });
|
|
40
|
+
if (original?.key === attentionKey && getAnswerRecord(block.blockId, root))
|
|
41
|
+
return { block, attention: original };
|
|
42
|
+
}
|
|
43
|
+
if (attention?.key === attentionKey) {
|
|
44
|
+
block = blockFromAttention(attention, session);
|
|
45
|
+
publishBlock(block, root);
|
|
46
|
+
return { block, attention };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
throw new Error(`No open attention item matches '${attentionKey}'.`);
|
|
50
|
+
}
|
|
51
|
+
/** Atomically claim the first answer, then route it over the session's recorded reply rail. */
|
|
52
|
+
export async function claimAndRouteAttentionAnswer(input) {
|
|
53
|
+
if ((input.choiceId == null) === (input.text == null))
|
|
54
|
+
throw new Error('Exactly one of choiceId or text is required.');
|
|
55
|
+
const sessions = input.sessions ?? await getActiveSessions();
|
|
56
|
+
const { block, attention } = await resolveBlock(input.attentionKey, sessions, input.feedRoot);
|
|
57
|
+
const choice = input.choiceId == null ? undefined : attention.choices?.find((item) => item.id === input.choiceId);
|
|
58
|
+
if (input.choiceId != null && !choice)
|
|
59
|
+
throw new Error(`Unknown choice '${input.choiceId}' for '${input.attentionKey}'.`);
|
|
60
|
+
const answer = input.text ?? choice?.deliveryKey ?? choice?.label;
|
|
61
|
+
if (!answer)
|
|
62
|
+
throw new Error('Answer is empty.');
|
|
63
|
+
const verified = input.operator.verified && verifyOperatorIdentity(input.operator.id);
|
|
64
|
+
const previousResolution = readResolution(block.blockId, input.feedRoot);
|
|
65
|
+
const claim = recordAnswer(block.blockId, {
|
|
66
|
+
answeredBy: input.operator.label,
|
|
67
|
+
answeredFrom: 'feed',
|
|
68
|
+
operatorId: input.operator.id,
|
|
69
|
+
verified,
|
|
70
|
+
}, input.feedRoot);
|
|
71
|
+
if (!claim.ok) {
|
|
72
|
+
if ('unauthorized' in claim)
|
|
73
|
+
throw new Error(claim.reason);
|
|
74
|
+
const existing = getAnswerRecord(block.blockId, input.feedRoot) ?? claim.existing;
|
|
75
|
+
return { status: 'already_answered', receipt: {
|
|
76
|
+
msgId: `answer-${block.blockId}`,
|
|
77
|
+
status: 'queued',
|
|
78
|
+
at: existing.answeredAt,
|
|
79
|
+
from: existing.answeredBy ?? existing.answeredFrom,
|
|
80
|
+
} };
|
|
81
|
+
}
|
|
82
|
+
const claimed = getAnswerRecord(block.blockId, input.feedRoot);
|
|
83
|
+
if (!claimed)
|
|
84
|
+
throw new Error(`Answer claim for '${input.attentionKey}' was not persisted.`);
|
|
85
|
+
const session = sessionForBlock(block, sessions);
|
|
86
|
+
const route = resolveAnswerRoute({ mailboxId: block.mailboxId, answer, block, session });
|
|
87
|
+
if (route.kind === 'refuse') {
|
|
88
|
+
rollbackAnswerClaim(block.blockId, claimed.answeredAt, block, previousResolution, input.feedRoot);
|
|
89
|
+
throw new Error(route.reason);
|
|
90
|
+
}
|
|
91
|
+
let msgId;
|
|
92
|
+
try {
|
|
93
|
+
if (route.kind === 'mailbox') {
|
|
94
|
+
msgId = enqueue(mailboxDir(block.mailboxId, input.mailboxRoot), {
|
|
95
|
+
to: block.mailboxId, text: answer, from: input.operator.label, blockId: block.blockId,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
else if (route.kind === 'resume') {
|
|
99
|
+
const invocation = getAgentsInvocation(resumeArgv(route));
|
|
100
|
+
const child = spawn(invocation.command, invocation.args, { stdio: 'inherit', env: process.env });
|
|
101
|
+
const code = await new Promise((resolve) => { child.once('error', () => resolve(1)); child.once('close', (value) => resolve(value ?? 1)); });
|
|
102
|
+
if (code !== 0)
|
|
103
|
+
throw new Error(`Resume delivery for '${input.attentionKey}' exited ${code}.`);
|
|
104
|
+
msgId = `resume-${Date.now()}`;
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
if (!route.inject || route.payload == null)
|
|
108
|
+
throw new Error(`Incomplete ${route.kind} reply rail.`);
|
|
109
|
+
const delivered = await injectIntoTerminal(route.inject, route.payload, { enter: true, combined: false });
|
|
110
|
+
if (!delivered.ok)
|
|
111
|
+
throw new Error(delivered.error ?? `Failed to deliver over ${route.kind}.`);
|
|
112
|
+
msgId = `inject-${Date.now()}`;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
rollbackAnswerClaim(block.blockId, claimed.answeredAt, block, previousResolution, input.feedRoot);
|
|
117
|
+
throw error;
|
|
118
|
+
}
|
|
119
|
+
const receipt = { msgId, status: 'queued', at: new Date().toISOString(), from: input.operator.label };
|
|
120
|
+
recordMessageReceipt(block.blockId, receipt, input.feedRoot);
|
|
121
|
+
return { status: 'delivered', receipt };
|
|
122
|
+
}
|
|
123
|
+
/** Forward a fleet attention answer to the device that owns its scope. */
|
|
124
|
+
export async function forwardFeedAnswer(input) {
|
|
125
|
+
const remoteArgs = ['feed', 'answer', input.attentionKey, '--json'];
|
|
126
|
+
if (input.choiceId != null)
|
|
127
|
+
remoteArgs.push('--choice', input.choiceId);
|
|
128
|
+
if (input.text != null)
|
|
129
|
+
remoteArgs.push('--text', input.text);
|
|
130
|
+
if (input.operatorId)
|
|
131
|
+
remoteArgs.push('--as', input.operatorId);
|
|
132
|
+
const invocation = getAgentsInvocation(['ssh', input.host, 'agents', ...remoteArgs]);
|
|
133
|
+
const child = spawn(invocation.command, invocation.args, { stdio: ['ignore', 'pipe', 'pipe'], env: process.env });
|
|
134
|
+
let stdout = '';
|
|
135
|
+
let stderr = '';
|
|
136
|
+
child.stdout.on('data', (chunk) => { stdout += String(chunk); });
|
|
137
|
+
child.stderr.on('data', (chunk) => { stderr += String(chunk); });
|
|
138
|
+
const code = await new Promise((resolve) => { child.once('error', () => resolve(1)); child.once('close', (value) => resolve(value ?? 1)); });
|
|
139
|
+
if (code !== 0)
|
|
140
|
+
throw new Error(stderr.trim() || `Remote feed answer on '${input.host}' exited ${code}.`);
|
|
141
|
+
const line = stdout.trim().split('\n').reverse().find((value) => value.startsWith('{'));
|
|
142
|
+
if (!line)
|
|
143
|
+
throw new Error(`Remote feed answer on '${input.host}' returned no JSON receipt.`);
|
|
144
|
+
return JSON.parse(line);
|
|
145
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { ActiveSession } from '../session/active.js';
|
|
2
|
+
import { type AttentionResolution, type AttentionSource, type AttentionState, type BlockOption, type BlockQuestion, type OpenBlock, type SourceCursor } from './feed.js';
|
|
3
|
+
/** What an attention item is asking of the operator. */
|
|
4
|
+
export type AttentionKind = 'question' | 'permission' | 'plan_review' | 'declared' | 'failure' | 'stall' | 'review';
|
|
5
|
+
/** How an answer can be routed back to the waiting agent. */
|
|
6
|
+
export type ReplyCapability = 'terminal' | 'tmux' | 'cloud' | 'team' | 'none';
|
|
7
|
+
/**
|
|
8
|
+
* One answerable choice on an attention item. Extends {@link BlockOption} with a
|
|
9
|
+
* stable `id` the UI echoes back when the operator picks it, and an optional
|
|
10
|
+
* `deliveryKey` (the harness-native keystroke / selection token) that Track B's
|
|
11
|
+
* answer router resolves the reply rail with.
|
|
12
|
+
*/
|
|
13
|
+
export interface AttentionChoice extends BlockOption {
|
|
14
|
+
id: string;
|
|
15
|
+
deliveryKey?: string;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* The canonical operator-facing attention record. One reconciled thing that needs
|
|
19
|
+
* a human, whatever produced it. This is the public envelope Tracks B and C
|
|
20
|
+
* consume — a stability contract, not an internal shape.
|
|
21
|
+
*/
|
|
22
|
+
export interface AttentionItem {
|
|
23
|
+
/** Stable identity: `host/session/generation`. A new generation is a new item. */
|
|
24
|
+
key: string;
|
|
25
|
+
sessionId: string;
|
|
26
|
+
mailboxId: string;
|
|
27
|
+
host: string;
|
|
28
|
+
project?: string;
|
|
29
|
+
kind: AttentionKind;
|
|
30
|
+
source: AttentionSource;
|
|
31
|
+
state: AttentionState;
|
|
32
|
+
/** ISO-8601 timestamp the ask opened. */
|
|
33
|
+
openedAt: string;
|
|
34
|
+
question?: BlockQuestion;
|
|
35
|
+
choices?: AttentionChoice[];
|
|
36
|
+
replyCapability: ReplyCapability;
|
|
37
|
+
safeDefault?: string;
|
|
38
|
+
/**
|
|
39
|
+
* Clusters IDENTICAL asks across different agents for batch triage. It is a hash
|
|
40
|
+
* of the RAW question intent (kind + verbatim question text + option labels) —
|
|
41
|
+
* deliberately NOT the UI-normalized display text, so two agents asking the same
|
|
42
|
+
* thing collapse even when the floor renders them differently.
|
|
43
|
+
*/
|
|
44
|
+
fingerprint: string;
|
|
45
|
+
/** Where in the source this generation sits — the fence a resolution compares against. */
|
|
46
|
+
sourceCursor?: SourceCursor;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* A pull-request attention signal, supplied BY the CLI (Track B refreshes it on a
|
|
50
|
+
* bounded TTL and hands it in). Track A only consumes the computed `needsHuman`
|
|
51
|
+
* flag — it never reaches into the extension's PR board or recomputes review
|
|
52
|
+
* state. Fields mirror the `gh pr view` projection so Track B can populate it.
|
|
53
|
+
*/
|
|
54
|
+
export interface PullRequestAttentionSignal {
|
|
55
|
+
number: number;
|
|
56
|
+
title?: string;
|
|
57
|
+
url?: string;
|
|
58
|
+
/** True when the PR is in a state that needs a human decision (review / merge). */
|
|
59
|
+
needsHuman: boolean;
|
|
60
|
+
reviewDecision?: string;
|
|
61
|
+
mergeable?: string;
|
|
62
|
+
state?: string;
|
|
63
|
+
isDraft?: boolean;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Fingerprint of an ask's raw intent. Uses the verbatim question text and option
|
|
67
|
+
* labels (NOT display-normalized text) so identical asks from different agents
|
|
68
|
+
* share a fingerprint for batch triage.
|
|
69
|
+
*/
|
|
70
|
+
export declare function attentionFingerprint(kind: AttentionKind, question?: BlockQuestion): string;
|
|
71
|
+
/**
|
|
72
|
+
* Reconcile the feed block ledger, the session lifecycle, a CLI-supplied PR
|
|
73
|
+
* signal, and the latest resolution tombstone into one attention item — or
|
|
74
|
+
* `undefined` when nothing needs a human. Pure; the extension never chooses
|
|
75
|
+
* authority.
|
|
76
|
+
*/
|
|
77
|
+
export declare function reconcileAttention(input: {
|
|
78
|
+
block?: OpenBlock;
|
|
79
|
+
session: ActiveSession;
|
|
80
|
+
pullRequest?: PullRequestAttentionSignal;
|
|
81
|
+
resolution?: AttentionResolution;
|
|
82
|
+
nowMs: number;
|
|
83
|
+
}): AttentionItem | undefined;
|
|
Binary file
|
package/dist/lib/feed/feed.d.ts
CHANGED
|
@@ -30,12 +30,54 @@ export interface AnswerRecord {
|
|
|
30
30
|
/** Whether the operator identity was verified against the registry. */
|
|
31
31
|
verified?: boolean;
|
|
32
32
|
}
|
|
33
|
+
/**
|
|
34
|
+
* Where an attention record came from, strongest evidence first.
|
|
35
|
+
* hook — a harness event (AskUserQuestion / permission / notification).
|
|
36
|
+
* declared — the agent said it is stuck (`agents feed post --blocked`).
|
|
37
|
+
* lifecycle — a structural session signal (plan handoff / permission wait).
|
|
38
|
+
* heuristic — an inferred prose question that decays.
|
|
39
|
+
* system — a synthetic card the feed computed (runaway / needy / PR review).
|
|
40
|
+
*/
|
|
41
|
+
export type AttentionSource = 'hook' | 'declared' | 'lifecycle' | 'heuristic' | 'system';
|
|
42
|
+
/**
|
|
43
|
+
* The lifecycle state of an attention record. A block is `open` until it is
|
|
44
|
+
* answered from some surface, `answered` once a surface claimed it, `consumed`
|
|
45
|
+
* once the agent read the reply, `continued` once the agent moved on, and
|
|
46
|
+
* `resolved` for a terminal clear (the block file is being removed). This is the
|
|
47
|
+
* axis the operator projection ranks on — only `open` needs a human.
|
|
48
|
+
*/
|
|
49
|
+
export type AttentionState = 'open' | 'answered' | 'consumed' | 'continued' | 'resolved';
|
|
50
|
+
/**
|
|
51
|
+
* A cursor into the source that produced a block — the transcript's last write
|
|
52
|
+
* (`lastActivityMs`) or a specific transcript `eventId`. It is what lets a
|
|
53
|
+
* resolution tombstone say "this generation was resolved at THIS point" so a
|
|
54
|
+
* stale lifecycle re-read cannot resurrect it while the session sits still, yet
|
|
55
|
+
* a genuinely newer turn (a strictly later cursor) is allowed through as a new
|
|
56
|
+
* generation. See {@link AttentionResolution} and `reconcileAttention`.
|
|
57
|
+
*/
|
|
58
|
+
export interface SourceCursor {
|
|
59
|
+
lastActivityMs?: number;
|
|
60
|
+
eventId?: string;
|
|
61
|
+
}
|
|
33
62
|
export interface OpenBlock {
|
|
34
63
|
blockId: string;
|
|
35
64
|
sessionId: string;
|
|
36
65
|
mailboxId: string;
|
|
37
66
|
host: string;
|
|
38
67
|
runtime: string;
|
|
68
|
+
/**
|
|
69
|
+
* Generation key for this block's CURRENT question. A new AskUserQuestion (or a
|
|
70
|
+
* new `--blocked` post) in the same session mints a new generation, so a
|
|
71
|
+
* resolution tombstone for the previous generation cannot suppress the fresh
|
|
72
|
+
* ask. Derived from `ts` when a writer did not stamp it — see {@link blockGeneration}.
|
|
73
|
+
*/
|
|
74
|
+
generation?: string;
|
|
75
|
+
/** How this block came to exist. Derived from {@link kind} when absent — see {@link blockSource}. */
|
|
76
|
+
source?: AttentionSource;
|
|
77
|
+
/** Lifecycle state. Derived from the answer/continue markers when absent — see {@link deriveBlockState}. */
|
|
78
|
+
state?: AttentionState;
|
|
79
|
+
/** Where in the source this block's generation sits — carried onto its resolution tombstone. */
|
|
80
|
+
sourceCursor?: SourceCursor;
|
|
39
81
|
/** Indexed launch origin, added at read time when the live session is known. */
|
|
40
82
|
origin?: 'cli' | 'routine';
|
|
41
83
|
/** Routine definition name when origin is `routine`. */
|
|
@@ -121,6 +163,66 @@ export interface FeedAskStats {
|
|
|
121
163
|
totalAskCount: number;
|
|
122
164
|
recentAskTimestamps: string[];
|
|
123
165
|
}
|
|
166
|
+
/**
|
|
167
|
+
* Why an attention generation stopped needing a human — a resolution tombstone.
|
|
168
|
+
* answered — a surface recorded an answer.
|
|
169
|
+
* continued — the agent consumed the answer and moved on.
|
|
170
|
+
* tool_completed — the tool an approval gated finished (permission cleared).
|
|
171
|
+
* expired — a decaying heuristic ask aged out.
|
|
172
|
+
* session_advanced— the transcript moved past the block (Stop/PostToolUse clear).
|
|
173
|
+
*/
|
|
174
|
+
export type ResolutionReason = 'answered' | 'continued' | 'tool_completed' | 'expired' | 'session_advanced';
|
|
175
|
+
/**
|
|
176
|
+
* A resolution tombstone. It is recorded BEFORE the open-block view is cleared so
|
|
177
|
+
* a resolved generation can never silently resurrect from a stale lifecycle
|
|
178
|
+
* re-read (the RUSH-1522 stale-flag class): the reconciler suppresses a
|
|
179
|
+
* lifecycle candidate whose generation the tombstone already covers, until the
|
|
180
|
+
* session advances strictly past `sourceCursor`. One tombstone per block id,
|
|
181
|
+
* latest-wins — the block id is stable per session, so this never grows unbounded.
|
|
182
|
+
*/
|
|
183
|
+
export interface AttentionResolution {
|
|
184
|
+
blockId: string;
|
|
185
|
+
/** The generation this tombstone resolved — matched against a fresh candidate's generation. */
|
|
186
|
+
generation: string;
|
|
187
|
+
/** ISO-8601 timestamp of the resolution. */
|
|
188
|
+
resolvedAt: string;
|
|
189
|
+
/** Where in the source the resolved generation sat; the reconciler compares a candidate's cursor against it. */
|
|
190
|
+
sourceCursor?: SourceCursor;
|
|
191
|
+
reason: ResolutionReason;
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Canonical generation for a block. A writer that stamped `generation` wins;
|
|
195
|
+
* otherwise the publish timestamp `ts` is the generation, because the feed
|
|
196
|
+
* rewrites `ts` only when a NEW question replaces the old one (an answer/continue
|
|
197
|
+
* update to the same block keeps `ts`). So `ts` changes exactly when the ask
|
|
198
|
+
* changes — which is what a generation must track.
|
|
199
|
+
*/
|
|
200
|
+
export declare function blockGeneration(block: OpenBlock): string;
|
|
201
|
+
/**
|
|
202
|
+
* Canonical source for a block. A writer that stamped `source` wins; otherwise it
|
|
203
|
+
* is derived from `kind`: a declared block is `declared`, a synthetic control card
|
|
204
|
+
* is `system`, everything else (question / notification, written by a harness hook)
|
|
205
|
+
* is `hook`.
|
|
206
|
+
*/
|
|
207
|
+
export declare function blockSource(block: OpenBlock): AttentionSource;
|
|
208
|
+
/**
|
|
209
|
+
* Canonical lifecycle state for a block. A writer that stamped `state` wins;
|
|
210
|
+
* otherwise it is derived from the markers already on the block: `continuedAt`
|
|
211
|
+
* means `continued`, a recorded `answer` means `answered`, and anything else is
|
|
212
|
+
* still `open`. This is the ONE place that turns the historical marker fields into
|
|
213
|
+
* the lifecycle axis, so no consumer re-derives it and drifts.
|
|
214
|
+
*/
|
|
215
|
+
export declare function deriveBlockState(block: OpenBlock): AttentionState;
|
|
216
|
+
/**
|
|
217
|
+
* Record a resolution tombstone (latest-wins per block id). Called from the
|
|
218
|
+
* answer / continue / clear paths BEFORE the open-block view is removed, so the
|
|
219
|
+
* reconciler always has the tombstone by the time the block file is gone.
|
|
220
|
+
*/
|
|
221
|
+
export declare function recordResolution(resolution: AttentionResolution, root?: string): void;
|
|
222
|
+
/** Read the latest resolution tombstone for a block, if one exists. */
|
|
223
|
+
export declare function readResolution(blockId: string, root?: string): AttentionResolution | undefined;
|
|
224
|
+
/** Read every resolution tombstone. Returns them sorted by stable block filename. */
|
|
225
|
+
export declare function listResolutions(root?: string): AttentionResolution[];
|
|
124
226
|
/**
|
|
125
227
|
* Stable block id for a session. One block per session -- a new question
|
|
126
228
|
* replaces the previous one (the agent can only ask one question at a time).
|
|
@@ -155,6 +257,13 @@ export declare function recordAnswer(blockId: string, answer: {
|
|
|
155
257
|
}, root?: string): RecordAnswerResult;
|
|
156
258
|
/** Read the answer record for a block, if one exists. */
|
|
157
259
|
export declare function getAnswerRecord(blockId: string, root?: string): AnswerRecord | undefined;
|
|
260
|
+
/**
|
|
261
|
+
* Release one specific answer claim after its reply rail failed. The compare on
|
|
262
|
+
* `answeredAt` makes this a conditional rollback: it can never erase a newer
|
|
263
|
+
* claimant. The caller supplies the exact pre-claim block/resolution snapshots,
|
|
264
|
+
* restoring the attention lifecycle to the state another surface observed.
|
|
265
|
+
*/
|
|
266
|
+
export declare function rollbackAnswerClaim(blockId: string, answeredAt: string, previousBlock: OpenBlock, previousResolution: AttentionResolution | undefined, root?: string): boolean;
|
|
158
267
|
/** True when the block has already been answered. */
|
|
159
268
|
export declare function isBlockAnswered(blockId: string, root?: string): boolean;
|
|
160
269
|
/**
|
package/dist/lib/feed/feed.js
CHANGED
|
@@ -29,6 +29,80 @@ import { getFeedDir, getUserAgentsDir } from '../state.js';
|
|
|
29
29
|
import { isAdmin, isHighConsequenceAllowed, isKnownOperator } from '../operator.js';
|
|
30
30
|
import { projectKeyFromCwd } from '../project-key.js';
|
|
31
31
|
import { atomicWriteJsonSync } from '../fs-atomic.js';
|
|
32
|
+
function resolutionDir(root) { return path.join(root, 'resolutions'); }
|
|
33
|
+
/**
|
|
34
|
+
* Canonical generation for a block. A writer that stamped `generation` wins;
|
|
35
|
+
* otherwise the publish timestamp `ts` is the generation, because the feed
|
|
36
|
+
* rewrites `ts` only when a NEW question replaces the old one (an answer/continue
|
|
37
|
+
* update to the same block keeps `ts`). So `ts` changes exactly when the ask
|
|
38
|
+
* changes — which is what a generation must track.
|
|
39
|
+
*/
|
|
40
|
+
export function blockGeneration(block) {
|
|
41
|
+
return block.generation ?? block.ts;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Canonical source for a block. A writer that stamped `source` wins; otherwise it
|
|
45
|
+
* is derived from `kind`: a declared block is `declared`, a synthetic control card
|
|
46
|
+
* is `system`, everything else (question / notification, written by a harness hook)
|
|
47
|
+
* is `hook`.
|
|
48
|
+
*/
|
|
49
|
+
export function blockSource(block) {
|
|
50
|
+
if (block.source)
|
|
51
|
+
return block.source;
|
|
52
|
+
switch (block.kind) {
|
|
53
|
+
case 'declared': return 'declared';
|
|
54
|
+
case 'control': return 'system';
|
|
55
|
+
default: return 'hook';
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Canonical lifecycle state for a block. A writer that stamped `state` wins;
|
|
60
|
+
* otherwise it is derived from the markers already on the block: `continuedAt`
|
|
61
|
+
* means `continued`, a recorded `answer` means `answered`, and anything else is
|
|
62
|
+
* still `open`. This is the ONE place that turns the historical marker fields into
|
|
63
|
+
* the lifecycle axis, so no consumer re-derives it and drifts.
|
|
64
|
+
*/
|
|
65
|
+
export function deriveBlockState(block) {
|
|
66
|
+
if (block.state)
|
|
67
|
+
return block.state;
|
|
68
|
+
if (block.continuedAt)
|
|
69
|
+
return 'continued';
|
|
70
|
+
if (block.answer)
|
|
71
|
+
return 'answered';
|
|
72
|
+
return 'open';
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Record a resolution tombstone (latest-wins per block id). Called from the
|
|
76
|
+
* answer / continue / clear paths BEFORE the open-block view is removed, so the
|
|
77
|
+
* reconciler always has the tombstone by the time the block file is gone.
|
|
78
|
+
*/
|
|
79
|
+
export function recordResolution(resolution, root) {
|
|
80
|
+
const dir = resolutionDir(root ?? getFeedDir());
|
|
81
|
+
ensureDir(dir);
|
|
82
|
+
atomicWriteJsonSync(path.join(dir, `${resolution.blockId}.json`), resolution);
|
|
83
|
+
}
|
|
84
|
+
/** Read the latest resolution tombstone for a block, if one exists. */
|
|
85
|
+
export function readResolution(blockId, root) {
|
|
86
|
+
return safeReadJson(path.join(resolutionDir(root ?? getFeedDir()), `${blockId}.json`));
|
|
87
|
+
}
|
|
88
|
+
/** Read every resolution tombstone. Returns them sorted by stable block filename. */
|
|
89
|
+
export function listResolutions(root) {
|
|
90
|
+
const dir = resolutionDir(root ?? getFeedDir());
|
|
91
|
+
let names;
|
|
92
|
+
try {
|
|
93
|
+
names = fs.readdirSync(dir);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return [];
|
|
97
|
+
}
|
|
98
|
+
const out = [];
|
|
99
|
+
for (const name of names.filter(n => n.endsWith('.json')).sort()) {
|
|
100
|
+
const parsed = safeReadJson(path.join(dir, name));
|
|
101
|
+
if (parsed?.blockId && parsed.generation && parsed.reason)
|
|
102
|
+
out.push(parsed);
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
32
106
|
/**
|
|
33
107
|
* Stable block id for a session. One block per session -- a new question
|
|
34
108
|
* replaces the previous one (the agent can only ask one question at a time).
|
|
@@ -131,9 +205,20 @@ export function recordAnswer(blockId, answer, root) {
|
|
|
131
205
|
}
|
|
132
206
|
throw err;
|
|
133
207
|
}
|
|
134
|
-
// Marker created successfully -- mirror the answer into the block file
|
|
208
|
+
// Marker created successfully -- mirror the answer into the block file and
|
|
209
|
+
// advance the lifecycle to `answered`. The resolution tombstone is written
|
|
210
|
+
// first, so if a stale lifecycle re-read races the block-file update the
|
|
211
|
+
// reconciler already refuses to resurrect this generation.
|
|
135
212
|
if (block) {
|
|
213
|
+
recordResolution({
|
|
214
|
+
blockId,
|
|
215
|
+
generation: blockGeneration(block),
|
|
216
|
+
resolvedAt: record.answeredAt,
|
|
217
|
+
sourceCursor: block.sourceCursor,
|
|
218
|
+
reason: 'answered',
|
|
219
|
+
}, dir);
|
|
136
220
|
block.answer = record;
|
|
221
|
+
block.state = 'answered';
|
|
137
222
|
publishBlock(block, dir);
|
|
138
223
|
}
|
|
139
224
|
return { ok: true };
|
|
@@ -142,6 +227,37 @@ export function recordAnswer(blockId, answer, root) {
|
|
|
142
227
|
export function getAnswerRecord(blockId, root) {
|
|
143
228
|
return safeReadJson(path.join(answeredDir(root ?? getFeedDir()), `${blockId}.json`));
|
|
144
229
|
}
|
|
230
|
+
/**
|
|
231
|
+
* Release one specific answer claim after its reply rail failed. The compare on
|
|
232
|
+
* `answeredAt` makes this a conditional rollback: it can never erase a newer
|
|
233
|
+
* claimant. The caller supplies the exact pre-claim block/resolution snapshots,
|
|
234
|
+
* restoring the attention lifecycle to the state another surface observed.
|
|
235
|
+
*/
|
|
236
|
+
export function rollbackAnswerClaim(blockId, answeredAt, previousBlock, previousResolution, root) {
|
|
237
|
+
const dir = root ?? getFeedDir();
|
|
238
|
+
const marker = path.join(answeredDir(dir), `${blockId}.json`);
|
|
239
|
+
const current = safeReadJson(marker);
|
|
240
|
+
if (!current || current.answeredAt !== answeredAt)
|
|
241
|
+
return false;
|
|
242
|
+
// Restore while the O_EXCL marker still excludes every other claimant. The
|
|
243
|
+
// marker is removed LAST; once another writer can win recordAnswer, this
|
|
244
|
+
// rollback has no state left to overwrite.
|
|
245
|
+
publishBlock(previousBlock, dir);
|
|
246
|
+
const resolutionFile = path.join(resolutionDir(dir), `${blockId}.json`);
|
|
247
|
+
if (previousResolution)
|
|
248
|
+
recordResolution(previousResolution, dir);
|
|
249
|
+
else {
|
|
250
|
+
try {
|
|
251
|
+
fs.unlinkSync(resolutionFile);
|
|
252
|
+
}
|
|
253
|
+
catch (error) {
|
|
254
|
+
if (error.code !== 'ENOENT')
|
|
255
|
+
throw error;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
fs.unlinkSync(marker);
|
|
259
|
+
return true;
|
|
260
|
+
}
|
|
145
261
|
/** True when the block has already been answered. */
|
|
146
262
|
export function isBlockAnswered(blockId, root) {
|
|
147
263
|
return fs.existsSync(path.join(answeredDir(root ?? getFeedDir()), `${blockId}.json`));
|
|
@@ -190,7 +306,16 @@ export function recordContinued(blockId, root) {
|
|
|
190
306
|
const block = readBlock(blockId, dir);
|
|
191
307
|
if (!block)
|
|
192
308
|
return;
|
|
193
|
-
|
|
309
|
+
const now = new Date().toISOString();
|
|
310
|
+
block.continuedAt = now;
|
|
311
|
+
block.state = 'continued';
|
|
312
|
+
recordResolution({
|
|
313
|
+
blockId,
|
|
314
|
+
generation: blockGeneration(block),
|
|
315
|
+
resolvedAt: now,
|
|
316
|
+
sourceCursor: block.sourceCursor,
|
|
317
|
+
reason: 'continued',
|
|
318
|
+
}, dir);
|
|
194
319
|
publishBlock(block, dir);
|
|
195
320
|
}
|
|
196
321
|
/** Mark a decision-class block as hard-parked (no safe default existed). */
|
|
@@ -260,13 +385,20 @@ export function buildDeclaredBlock(agent, input) {
|
|
|
260
385
|
.filter(Boolean)
|
|
261
386
|
.map((label) => ({ label }));
|
|
262
387
|
const project = projectKeyFromCwd(agent.cwd);
|
|
388
|
+
const ts = input.ts ?? new Date().toISOString();
|
|
263
389
|
return {
|
|
264
390
|
blockId: blockIdForSession(agent.sessionId),
|
|
265
391
|
sessionId: agent.sessionId,
|
|
266
392
|
mailboxId: agent.mailboxId,
|
|
267
393
|
host: agent.host,
|
|
268
394
|
runtime: agent.runtime,
|
|
269
|
-
ts
|
|
395
|
+
ts,
|
|
396
|
+
// A declared block is an explicit, agent-raised attention record: it opens the
|
|
397
|
+
// lifecycle here, sourced `declared`, with `ts` as its generation so a later
|
|
398
|
+
// `--blocked` in the same session mints a fresh generation past any tombstone.
|
|
399
|
+
generation: ts,
|
|
400
|
+
source: 'declared',
|
|
401
|
+
state: 'open',
|
|
270
402
|
kind: 'declared',
|
|
271
403
|
questions: [{ text, header: 'Needs you', ...(options.length ? { options } : {}) }],
|
|
272
404
|
blockClass: input.safeDefault ? 'approval' : 'decision',
|
|
@@ -337,6 +469,25 @@ export function listAskStats(root) {
|
|
|
337
469
|
/** Remove a block record and its lifecycle sidecars. Returns true if the file was deleted. */
|
|
338
470
|
export function removeBlock(blockId, root) {
|
|
339
471
|
const dir = root ?? getFeedDir();
|
|
472
|
+
// Record a resolution tombstone BEFORE the block file and answered marker are
|
|
473
|
+
// gone, so a stale lifecycle re-read of the same session cannot resurrect this
|
|
474
|
+
// cleared generation. The reason names how it closed: an answered marker means
|
|
475
|
+
// the operator answered, `continuedAt` means the agent moved on, otherwise the
|
|
476
|
+
// transcript advanced past it (a Stop/PostToolUse clear). The tombstone itself
|
|
477
|
+
// is deliberately NOT cleared — it must outlive the block to do its job.
|
|
478
|
+
const block = readBlock(blockId, dir);
|
|
479
|
+
if (block) {
|
|
480
|
+
const reason = isBlockAnswered(blockId, dir)
|
|
481
|
+
? 'answered'
|
|
482
|
+
: block.continuedAt ? 'continued' : 'session_advanced';
|
|
483
|
+
recordResolution({
|
|
484
|
+
blockId,
|
|
485
|
+
generation: blockGeneration(block),
|
|
486
|
+
resolvedAt: new Date().toISOString(),
|
|
487
|
+
sourceCursor: block.sourceCursor,
|
|
488
|
+
reason,
|
|
489
|
+
}, dir);
|
|
490
|
+
}
|
|
340
491
|
clearBlockLifecycle(blockId, dir);
|
|
341
492
|
try {
|
|
342
493
|
fs.unlinkSync(blockPath(dir, blockId));
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { ActiveSession } from '../session/active.js';
|
|
2
|
+
import type { PullRequestAttentionSignal } from './attention.js';
|
|
3
|
+
export declare const PR_STATUS_TTL_MS = 45000;
|
|
4
|
+
export declare const PR_STATUS_FIELDS = "number,title,state,isDraft,reviewDecision,mergeable,statusCheckRollup";
|
|
5
|
+
export interface PullRequestStatus extends PullRequestAttentionSignal {
|
|
6
|
+
statusCheckRollup?: unknown[];
|
|
7
|
+
}
|
|
8
|
+
/** CLI-owned bounded-TTL source shared by feed attention and PR-board consumers. */
|
|
9
|
+
export declare function readPullRequestStatus(session: ActiveSession, options?: {
|
|
10
|
+
nowMs?: number;
|
|
11
|
+
ttlMs?: number;
|
|
12
|
+
}): Promise<PullRequestStatus | undefined>;
|
|
13
|
+
export declare function projectPullRequestBoard(sessions: ActiveSession[]): Promise<PullRequestStatus[]>;
|
|
14
|
+
export declare function resetPullRequestStatusCache(): void;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
const execFileAsync = promisify(execFile);
|
|
4
|
+
export const PR_STATUS_TTL_MS = 45_000;
|
|
5
|
+
export const PR_STATUS_FIELDS = 'number,title,state,isDraft,reviewDecision,mergeable,statusCheckRollup';
|
|
6
|
+
const cache = new Map();
|
|
7
|
+
function needsHuman(value) {
|
|
8
|
+
if (value.state !== 'OPEN' || value.isDraft)
|
|
9
|
+
return false;
|
|
10
|
+
const checks = value.statusCheckRollup ?? [];
|
|
11
|
+
const checksSettled = checks.every((check) => {
|
|
12
|
+
const row = check;
|
|
13
|
+
return row.conclusion === 'SUCCESS' || row.conclusion === 'NEUTRAL' || row.status === 'COMPLETED';
|
|
14
|
+
});
|
|
15
|
+
return value.reviewDecision !== 'APPROVED' || (value.mergeable === 'MERGEABLE' && checksSettled);
|
|
16
|
+
}
|
|
17
|
+
/** CLI-owned bounded-TTL source shared by feed attention and PR-board consumers. */
|
|
18
|
+
export async function readPullRequestStatus(session, options = {}) {
|
|
19
|
+
const ref = session.pr?.url ?? session.pr?.number;
|
|
20
|
+
if (!ref || !session.cwd)
|
|
21
|
+
return undefined;
|
|
22
|
+
const key = `${session.cwd}\0${String(ref)}`;
|
|
23
|
+
const now = options.nowMs ?? Date.now();
|
|
24
|
+
const hit = cache.get(key);
|
|
25
|
+
if (hit && hit.expiresAt > now)
|
|
26
|
+
return hit.value;
|
|
27
|
+
try {
|
|
28
|
+
const { stdout } = await execFileAsync('gh', ['pr', 'view', String(ref), '--json', PR_STATUS_FIELDS], {
|
|
29
|
+
cwd: session.cwd,
|
|
30
|
+
timeout: 15_000,
|
|
31
|
+
maxBuffer: 1024 * 1024,
|
|
32
|
+
});
|
|
33
|
+
const raw = JSON.parse(stdout);
|
|
34
|
+
const value = { ...raw, url: session.pr?.url, needsHuman: needsHuman(raw) };
|
|
35
|
+
cache.set(key, { expiresAt: now + (options.ttlMs ?? PR_STATUS_TTL_MS), value });
|
|
36
|
+
return value;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
cache.set(key, { expiresAt: now + (options.ttlMs ?? PR_STATUS_TTL_MS) });
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
export async function projectPullRequestBoard(sessions) {
|
|
44
|
+
const rows = await Promise.all(sessions.map((session) => readPullRequestStatus(session)));
|
|
45
|
+
return rows.filter((row) => row !== undefined);
|
|
46
|
+
}
|
|
47
|
+
export function resetPullRequestStatusCache() { cache.clear(); }
|