handmux 0.23.0 → 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -8
- package/README.zh-CN.md +31 -8
- package/bin/handmux.js +14 -688
- package/bin/node-version.js +20 -0
- package/dist/bin/handmux.js +1015 -0
- package/dist/connectors/bridgeClient.js +385 -0
- package/dist/connectors/claude/index.js +476 -0
- package/dist/connectors/pi/bridgeClient.js +8 -0
- package/dist/connectors/pi/index.js +851 -0
- package/dist/hooks/handmux-notify.sh +64 -0
- package/{hooks → dist/hooks}/handmux-statusline.cjs +28 -6
- package/dist/hooks/handmux-write.cjs +264 -0
- package/dist/package.json +127 -0
- package/dist/public/assets/index-CTrqTqtT.css +32 -0
- package/dist/public/assets/index-DmjeMSQA.js +346 -0
- package/dist/public/assets/pcm-worklet-CxcDy7PB.js +1 -0
- package/{public → dist/public}/index.html +5 -4
- package/dist/src/agent-runtime/adapter.js +204 -0
- package/dist/src/agent-runtime/bridge.js +1006 -0
- package/dist/src/agent-runtime/bridgeStore.js +25 -0
- package/dist/src/agent-runtime/bridgeTransport.js +619 -0
- package/dist/src/agent-runtime/bridgeTypes.js +13 -0
- package/dist/src/agent-runtime/builtinRuntime.js +193 -0
- package/dist/src/agent-runtime/conversation.js +2179 -0
- package/dist/src/agent-runtime/conversationActivation.js +75 -0
- package/dist/src/agent-runtime/conversationActivity.js +69 -0
- package/dist/src/agent-runtime/conversationControls.js +206 -0
- package/dist/src/agent-runtime/conversationLiveHub.js +276 -0
- package/dist/src/agent-runtime/conversationStore.js +352 -0
- package/dist/src/agent-runtime/conversationTypes.js +1 -0
- package/dist/src/agent-runtime/conversationValidation.js +339 -0
- package/dist/src/agent-runtime/inbox.js +973 -0
- package/dist/src/agent-runtime/inboxPushProjection.js +69 -0
- package/dist/src/agent-runtime/inboxStore.js +25 -0
- package/dist/src/agent-runtime/inboxTypes.js +1 -0
- package/dist/src/agent-runtime/interaction.js +829 -0
- package/dist/src/agent-runtime/interactionLiveHub.js +225 -0
- package/dist/src/agent-runtime/interactionStore.js +18 -0
- package/dist/src/agent-runtime/interactionTypes.js +1 -0
- package/dist/src/agent-runtime/legacyInboxProjection.js +70 -0
- package/dist/src/agent-runtime/migrateLegacyCodexOutbox.js +336 -0
- package/dist/src/agent-runtime/resourceTypes.js +1 -0
- package/dist/src/agent-runtime/resources.js +245 -0
- package/dist/src/agent-runtime/run.js +328 -0
- package/dist/src/agent-runtime/runtime.js +793 -0
- package/dist/src/agent-runtime/sessionControl.js +164 -0
- package/dist/src/agent-runtime/subscriptionUsage.js +190 -0
- package/dist/src/agent-runtime/subscriptionUsageLegacy.js +18 -0
- package/dist/src/agent-runtime/tmuxRuntime.js +156 -0
- package/dist/src/agents/claude.js +203 -0
- package/dist/src/agents/claudeConversation.js +362 -0
- package/dist/src/agents/claudeInteraction.js +134 -0
- package/dist/src/agents/claudeSubscriptionUsage.js +61 -0
- package/dist/src/agents/codex.js +262 -0
- package/dist/src/agents/codexConversation.js +1103 -0
- package/dist/src/agents/codexConversationActivation.js +121 -0
- package/dist/src/agents/codexConversationControls.js +256 -0
- package/dist/src/agents/codexInteraction.js +306 -0
- package/dist/src/agents/codexSessionControl.js +118 -0
- package/dist/src/agents/codexSubscriptionUsage.js +313 -0
- package/dist/src/agents/codexTerminalControl.js +23 -0
- package/dist/src/agents/conversationProjectionSafety.js +416 -0
- package/dist/src/agents/index.js +39 -0
- package/dist/src/agents/nativeInbox.js +264 -0
- package/dist/src/agents/nativeInboxSources.js +115 -0
- package/dist/src/agents/pi.js +28 -0
- package/dist/src/agents/piConversation.js +523 -0
- package/dist/src/agents/piConversationHistory.js +502 -0
- package/dist/src/agents/piInboxBridge.js +368 -0
- package/dist/src/agents/piSessionControl.js +32 -0
- package/dist/src/agents/processIdentity.js +94 -0
- package/dist/src/agents/scanUtils.js +262 -0
- package/dist/src/apiAccountProviders.js +194 -0
- package/dist/src/apiAccounts.js +592 -0
- package/dist/src/apiErrors.js +86 -0
- package/{src → dist/src}/appName.js +12 -13
- package/dist/src/asr/iflyConfig.js +7 -0
- package/dist/src/asr/iflySign.js +14 -0
- package/dist/src/auth.js +27 -0
- package/dist/src/browser/bootstrap.js +72 -0
- package/dist/src/browser/cookieProfiles.js +413 -0
- package/dist/src/browser/coordinator.js +51 -0
- package/dist/src/browser/hammerheadCompat.js +104 -0
- package/dist/src/browser/hammerheadRedirectCompat.js +37 -0
- package/dist/src/browser/manager.js +560 -0
- package/dist/src/browser/originLabel.js +15 -0
- package/dist/src/browser/profilePersistence.js +235 -0
- package/dist/src/browser/publicProxy.js +176 -0
- package/dist/src/browser/routes.js +159 -0
- package/dist/src/browser/sessionStore.js +72 -0
- package/dist/src/browser/siteVersion.js +121 -0
- package/dist/src/browser/targetPolicy.js +163 -0
- package/{src → dist/src}/browser/worker.js +14 -18
- package/dist/src/browser/workerClient.js +286 -0
- package/dist/src/browser/workerServer.js +91 -0
- package/dist/src/captureBackground.js +95 -0
- package/dist/src/claudeEvents.js +525 -0
- package/dist/src/cli/agentIntegration.js +131 -0
- package/dist/src/cli/agentLauncher.js +26 -0
- package/dist/src/cli/claudeHooks.js +290 -0
- package/{src → dist/src}/cli/cloudflareUrl.js +2 -3
- package/dist/src/cli/cloudflared.js +106 -0
- package/dist/src/cli/codexManaged.js +80 -0
- package/{src → dist/src}/cli/cpolarUrl.js +8 -9
- package/dist/src/cli/drivers.js +98 -0
- package/dist/src/cli/hookScaffold.js +39 -0
- package/dist/src/cli/i18n/en.js +392 -0
- package/{src → dist/src}/cli/i18n/index.js +27 -25
- package/dist/src/cli/i18n/zh.js +391 -0
- package/dist/src/cli/legacyCodexHooks.js +52 -0
- package/{src → dist/src}/cli/natappUrl.js +2 -3
- package/dist/src/cli/options.js +245 -0
- package/dist/src/cli/piExtension.js +155 -0
- package/dist/src/cli/probe.js +16 -0
- package/{src → dist/src}/cli/prompt.js +4 -6
- package/dist/src/cli/pushCmd.js +102 -0
- package/{src → dist/src}/cli/qr.js +19 -19
- package/dist/src/cli/service.js +154 -0
- package/dist/src/cli/setupModel.js +194 -0
- package/dist/src/cli/setupWizard.js +647 -0
- package/dist/src/cli/shortcutEditor.js +351 -0
- package/dist/src/cli/sshTunnel.js +18 -0
- package/dist/src/cli/state.js +117 -0
- package/dist/src/cli/statusLine.js +115 -0
- package/dist/src/cli/supervisor.js +278 -0
- package/dist/src/cli/supervisorHealth.js +21 -0
- package/dist/src/cli/supervisorLaunch.js +148 -0
- package/dist/src/cli/supervisorProcesses.js +62 -0
- package/dist/src/cli/supervisorState.js +62 -0
- package/dist/src/cli/tmuxVersion.js +53 -0
- package/{src → dist/src}/cli/tunlite.js +7 -10
- package/dist/src/cli/tunnelClients.js +72 -0
- package/{src → dist/src}/cli/updateCheck.js +98 -69
- package/{src → dist/src}/cli/urlHost.js +8 -4
- package/dist/src/cli/workspaceCmd.js +347 -0
- package/dist/src/codexAppServer.js +3436 -0
- package/dist/src/codexConversationProjection.js +199 -0
- package/dist/src/codexDiff.js +118 -0
- package/dist/src/codexMessageIdentity.js +18 -0
- package/dist/src/codexPlan.js +30 -0
- package/dist/src/codexQueueProtocol.js +137 -0
- package/dist/src/codexStreamProtocol.js +207 -0
- package/dist/src/codexToolProtocol.js +68 -0
- package/dist/src/codexTranscriptParse.js +835 -0
- package/dist/src/codexUsageSnapshot.js +228 -0
- package/dist/src/config.js +8 -0
- package/{src → dist/src}/docPath.js +22 -24
- package/dist/src/docs.js +337 -0
- package/dist/src/git.js +284 -0
- package/dist/src/healthProtocol.js +97 -0
- package/dist/src/httpApi.js +96 -0
- package/dist/src/jsonStore.js +15 -0
- package/{src → dist/src}/keyNames.js +3 -3
- package/dist/src/notifications.js +111 -0
- package/dist/src/orphans.js +167 -0
- package/dist/src/paneInput.js +42 -0
- package/dist/src/pendingPrompt.js +194 -0
- package/dist/src/previewServer.js +202 -0
- package/dist/src/previews.js +179 -0
- package/dist/src/privateStateStore.js +124 -0
- package/dist/src/projectTask/backup.js +224 -0
- package/dist/src/projectTask/lock.js +108 -0
- package/dist/src/projectTask/migrations.js +90 -0
- package/dist/src/projectTask/routes.js +155 -0
- package/dist/src/projectTask/runtime.js +182 -0
- package/dist/src/projectTask/schema.js +56 -0
- package/dist/src/projectTask/store.js +457 -0
- package/dist/src/push.js +224 -0
- package/dist/src/routes/agents.js +631 -0
- package/dist/src/routes/apiAccounts.js +82 -0
- package/dist/src/routes/files.js +230 -0
- package/dist/src/routes/git.js +89 -0
- package/dist/src/routes/health.js +16 -0
- package/dist/src/routes/notifications.js +16 -0
- package/dist/src/routes/previews.js +43 -0
- package/dist/src/routes/push.js +145 -0
- package/dist/src/routes/sessions.js +244 -0
- package/dist/src/routes/system.js +220 -0
- package/dist/src/routes/terminal.js +244 -0
- package/dist/src/routes/workspace.js +267 -0
- package/dist/src/server.js +315 -0
- package/dist/src/shortcutConfig.js +83 -0
- package/{src → dist/src}/staticCache.js +5 -3
- package/{src → dist/src}/staticCompression.js +5 -6
- package/dist/src/terminalStream.js +441 -0
- package/{src → dist/src}/tmux/commands.js +185 -172
- package/dist/src/tmux/format.js +47 -0
- package/dist/src/transcriptParse.js +261 -0
- package/dist/src/transcriptReader.js +209 -0
- package/{src → dist/src}/trimCapture.js +11 -11
- package/dist/src/uploadTypes.js +31 -0
- package/dist/src/urlPolicy.js +18 -0
- package/dist/src/usagePaths.js +15 -0
- package/dist/src/workspace/agentRunner.js +301 -0
- package/dist/src/workspace/atomicJson.js +31 -0
- package/dist/src/workspace/capture.js +139 -0
- package/dist/src/workspace/checkpointer.js +228 -0
- package/dist/src/workspace/environment.js +71 -0
- package/dist/src/workspace/lock.js +137 -0
- package/dist/src/workspace/mapping.js +128 -0
- package/dist/src/workspace/operations.js +271 -0
- package/dist/src/workspace/paths.js +15 -0
- package/dist/src/workspace/planner.js +292 -0
- package/dist/src/workspace/restore.js +363 -0
- package/dist/src/workspace/runtime.js +318 -0
- package/dist/src/workspace/schema.js +156 -0
- package/dist/src/workspace/store.js +464 -0
- package/dist/src/workspace/tmuxAdapter.js +704 -0
- package/dist/src/workspaceProtocol.js +158 -0
- package/package.json +30 -17
- package/hooks/handmux-notify.sh +0 -22
- package/hooks/handmux-write.cjs +0 -78
- package/public/assets/index-C3N7OkyP.css +0 -32
- package/public/assets/index-D1DWcC5u.js +0 -337
- package/src/.gitkeep +0 -0
- package/src/agents/claude.js +0 -159
- package/src/agents/codex.js +0 -168
- package/src/agents/index.js +0 -21
- package/src/agents/processIdentity.js +0 -72
- package/src/agents/scanUtils.js +0 -225
- package/src/asr/iflyConfig.js +0 -10
- package/src/asr/iflySign.js +0 -16
- package/src/auth.js +0 -30
- package/src/browser/bootstrap.js +0 -68
- package/src/browser/cookieProfiles.js +0 -403
- package/src/browser/coordinator.js +0 -47
- package/src/browser/hammerheadCompat.js +0 -86
- package/src/browser/hammerheadRedirectCompat.js +0 -38
- package/src/browser/manager.js +0 -556
- package/src/browser/originLabel.js +0 -17
- package/src/browser/profilePersistence.js +0 -241
- package/src/browser/publicProxy.js +0 -175
- package/src/browser/routes.js +0 -154
- package/src/browser/sessionStore.js +0 -78
- package/src/browser/siteVersion.js +0 -124
- package/src/browser/targetPolicy.js +0 -137
- package/src/browser/workerClient.js +0 -253
- package/src/browser/workerServer.js +0 -98
- package/src/captureBackground.js +0 -93
- package/src/claudeEvents.js +0 -399
- package/src/cli/claudeHooks.js +0 -206
- package/src/cli/cloudflared.js +0 -99
- package/src/cli/codexManaged.js +0 -77
- package/src/cli/drivers.js +0 -97
- package/src/cli/hookScaffold.js +0 -33
- package/src/cli/i18n/en.js +0 -385
- package/src/cli/i18n/zh.js +0 -384
- package/src/cli/legacyCodexHooks.js +0 -49
- package/src/cli/options.js +0 -211
- package/src/cli/probe.js +0 -16
- package/src/cli/pushCmd.js +0 -60
- package/src/cli/service.js +0 -126
- package/src/cli/setupModel.js +0 -154
- package/src/cli/setupWizard.js +0 -506
- package/src/cli/shortcutEditor.js +0 -288
- package/src/cli/sshTunnel.js +0 -12
- package/src/cli/state.js +0 -90
- package/src/cli/statusLine.js +0 -90
- package/src/cli/supervisor.js +0 -183
- package/src/cli/supervisorProcesses.js +0 -57
- package/src/cli/tmuxVersion.js +0 -49
- package/src/cli/tunnelClients.js +0 -62
- package/src/cli/workspaceCmd.js +0 -300
- package/src/codexAppServer.js +0 -1620
- package/src/codexDiff.js +0 -108
- package/src/codexTranscriptParse.js +0 -413
- package/src/codexUsageSnapshot.cjs +0 -129
- package/src/config.js +0 -9
- package/src/docs.js +0 -228
- package/src/git.js +0 -208
- package/src/httpApi.js +0 -72
- package/src/jsonStore.js +0 -24
- package/src/notifications.js +0 -68
- package/src/orphans.js +0 -152
- package/src/pendingPrompt.js +0 -163
- package/src/previewServer.js +0 -168
- package/src/previews.js +0 -116
- package/src/push.js +0 -183
- package/src/routes/codex.js +0 -473
- package/src/routes/files.js +0 -172
- package/src/routes/git.js +0 -57
- package/src/routes/notifications.js +0 -19
- package/src/routes/previews.js +0 -35
- package/src/routes/push.js +0 -119
- package/src/routes/sessions.js +0 -168
- package/src/routes/system.js +0 -132
- package/src/routes/terminal.js +0 -210
- package/src/routes/transcript.js +0 -171
- package/src/routes/workspace.js +0 -267
- package/src/server.js +0 -186
- package/src/shortcutConfig.js +0 -75
- package/src/terminalStream.js +0 -391
- package/src/tmux/format.js +0 -45
- package/src/transcriptParse.js +0 -180
- package/src/transcriptReader.js +0 -101
- package/src/uploadTypes.js +0 -30
- package/src/urlPolicy.js +0 -14
- package/src/usage.js +0 -260
- package/src/workspace/agentRunner.js +0 -188
- package/src/workspace/atomicJson.js +0 -32
- package/src/workspace/capture.js +0 -139
- package/src/workspace/checkpointer.js +0 -187
- package/src/workspace/environment.js +0 -75
- package/src/workspace/lock.js +0 -128
- package/src/workspace/mapping.js +0 -124
- package/src/workspace/operations.js +0 -252
- package/src/workspace/paths.js +0 -16
- package/src/workspace/planner.js +0 -247
- package/src/workspace/restore.js +0 -279
- package/src/workspace/runtime.js +0 -314
- package/src/workspace/schema.js +0 -136
- package/src/workspace/store.js +0 -382
- package/src/workspace/tmuxAdapter.js +0 -554
- /package/{public → dist/public}/fonts/JetBrainsMonoNerdFontMono-Regular.woff2 +0 -0
- /package/{public → dist/public}/fonts/TWUnifont.woff2 +0 -0
- /package/{public → dist/public}/icons/apple-touch-icon.png +0 -0
- /package/{public → dist/public}/icons/badge-96.png +0 -0
- /package/{public → dist/public}/icons/icon-192-maskable.png +0 -0
- /package/{public → dist/public}/icons/icon-192.png +0 -0
- /package/{public → dist/public}/icons/icon-512-maskable.png +0 -0
- /package/{public → dist/public}/icons/icon-512.png +0 -0
- /package/{public → dist/public}/icons/logo.svg +0 -0
- /package/{public → dist/public}/manifest.webmanifest +0 -0
- /package/{public → dist/public}/offline.html +0 -0
- /package/{public → dist/public}/sw.js +0 -0
- /package/{src → dist/src}/browser/protocol.js +0 -0
- /package/{src → dist/src}/cli/cfNamed.js +0 -0
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { getAgent, agentForProc } from './agents/index.js';
|
|
5
|
+
import { resolveVersionedComms } from './agents/claude.js';
|
|
6
|
+
import { resolveCodexComms } from './agents/codex.js';
|
|
7
|
+
import { defaultRun } from './agents/scanUtils.js';
|
|
8
|
+
import { claude } from './agents/claude.js';
|
|
9
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
// The hook-maintained state file: ONE JSON object keyed by tmux pane id, each value the pane's latest
|
|
11
|
+
// event { ts, src, host, payload }. The hook writes it (handmux-write.js); the server only reads it.
|
|
12
|
+
// Default lives under server/data (gitignored runtime data); override with CLAUDE_STATE_FILE.
|
|
13
|
+
export const DEFAULT_STATE_FILE = process.env.CLAUDE_STATE_FILE || path.resolve(here, '../data/claude-state.json');
|
|
14
|
+
// Classify a Claude hook event → inbox kind. Re-exported because tests and callers import this path.
|
|
15
|
+
export const classifyEvent = claude.classify;
|
|
16
|
+
const EVENT_KINDS = new Set([
|
|
17
|
+
'done', 'working', 'permission', 'compacting', 'error', 'end', 'idle',
|
|
18
|
+
]);
|
|
19
|
+
const VIEW_LABEL = { needs: '需要你', done: '已完成' };
|
|
20
|
+
const recordOf = (value) => value && typeof value === 'object' && !Array.isArray(value)
|
|
21
|
+
? value : null;
|
|
22
|
+
const eventKind = (value) => typeof value === 'string' && EVENT_KINDS.has(value)
|
|
23
|
+
? value : null;
|
|
24
|
+
const pushView = (kind) => kind === 'permission' ? 'needs' : kind === 'done' ? 'done' : null;
|
|
25
|
+
// The dedup key for a pane's current push view. `needs` is view-only: Claude signals one permission gate via
|
|
26
|
+
// TWO hooks (permreq then permission_prompt) at different ts, and both must collapse to a SINGLE 需要你. But
|
|
27
|
+
// `done` is ts-sensitive: each finished turn is a fresh "已完成 / 该你了". For Claude, two dones are normally
|
|
28
|
+
// separated by a 进行中 that re-arms the dedup; the timestamp also protects unusual missing-start sequences.
|
|
29
|
+
function pushKey(view, ts) { return view === 'done' ? `done:${ts}` : view; }
|
|
30
|
+
// A 进行中 (working) is a LATCHED state: set by UserPromptSubmit, normally closed by Stop. But an ESC
|
|
31
|
+
// interrupt / walk-away fires NO hook at all (verified across all 26 hook event types), so working never
|
|
32
|
+
// gets closed and the blue dot would stick forever. There's no event signal for the interrupt — so we
|
|
33
|
+
// expire working purely by age: latched longer than this with no done/needs ⇒ no-longer-working, drop it.
|
|
34
|
+
// Generous (2h) so a genuinely long-running task keeps its dot for its whole run; a real turn re-lights on
|
|
35
|
+
// its next event, and a new prompt resets the clock.
|
|
36
|
+
const WORKING_TTL_MS = 2 * 60 * 60 * 1000;
|
|
37
|
+
// 压缩中 (compacting) clears three ways: a SUCCESSFUL compaction fires PostCompact (src 'compact' → cleared
|
|
38
|
+
// the instant it finishes); a NO-OP /compact ("Not enough messages to compact") fires no PostCompact but
|
|
39
|
+
// writes its <local-command-stdout> immediately, which the transcript-tail check below catches within a poll;
|
|
40
|
+
// and this TTL is the ultimate backstop for a genuine crash/abort mid-compaction where neither signal comes.
|
|
41
|
+
// It must stay WELL above a real compaction's duration (routinely 1-2min) so it never truncates the animation
|
|
42
|
+
// of a compaction that's actually still running — real ones are cleared by PostCompact, not by this timer.
|
|
43
|
+
const COMPACTING_TTL_MS = 5 * 60 * 1000;
|
|
44
|
+
// A pane latched in `permission` (需要你) has NO hook event to close it when the user resolves the prompt:
|
|
45
|
+
// approving a normal tool (Bash/Edit/…) doesn't hit our PostToolUse matcher (only AskUserQuestion|ExitPlanMode
|
|
46
|
+
// do), and DENYING or ESC-interrupting fires no hook at all (verified across all hook events). So the prompt
|
|
47
|
+
// would read 需要你 until the turn's eventual Stop — or FOREVER after an ESC, which never Stops. Detect the
|
|
48
|
+
// resolution out-of-band: while the prompt is pending Claude is blocked and appends nothing to its session
|
|
49
|
+
// transcript, so the transcript's mtime sits at (or before) the permission event's ts; the instant the user
|
|
50
|
+
// resolves it — approve → tool_result, deny → denial result, ESC → a `[Request interrupted by user]` line —
|
|
51
|
+
// the transcript grows and its mtime jumps past it. mtime beyond the event ts (+ a guard for the near-
|
|
52
|
+
// simultaneous permreq/tool-write case) ⇒ resolved ⇒ drop the stale 需要你 (the pane falls back to neutral
|
|
53
|
+
// process-presence). The guard must clear the gap between the tool_use write and the permreq/notification
|
|
54
|
+
// firing; a real resolution always lands well beyond it (the user takes seconds to answer).
|
|
55
|
+
const PERM_RESOLVED_GUARD_MS = 1500;
|
|
56
|
+
// Read a transcript file's mtime in ms, or null if it's missing/unreadable (→ can't tell, keep 需要你).
|
|
57
|
+
function defaultStatMtime(file) { try {
|
|
58
|
+
return fs.statSync(file).mtimeMs;
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return null;
|
|
62
|
+
} }
|
|
63
|
+
// Read the LAST complete JSON line of a (possibly multi-MB) transcript, or null if unreadable. Reads only a
|
|
64
|
+
// bounded tail so cost is one small read regardless of transcript size; a last line longer than the window
|
|
65
|
+
// won't parse cleanly, but the only line we care to recognise (the interrupt marker) is tiny.
|
|
66
|
+
function defaultReadTail(file) {
|
|
67
|
+
try {
|
|
68
|
+
const fd = fs.openSync(file, 'r');
|
|
69
|
+
try {
|
|
70
|
+
const size = fs.fstatSync(fd).size;
|
|
71
|
+
const len = Math.min(size, 65536);
|
|
72
|
+
const buf = Buffer.alloc(len);
|
|
73
|
+
fs.readSync(fd, buf, 0, len, size - len);
|
|
74
|
+
const lines = buf.toString('utf8').split('\n').filter((line) => line.trim());
|
|
75
|
+
return lines.at(-1) ?? null;
|
|
76
|
+
}
|
|
77
|
+
finally {
|
|
78
|
+
fs.closeSync(fd);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
// Pure: has the user resolved the permission prompt recorded by `rec`, judged by its transcript mtime?
|
|
86
|
+
// True only once the transcript has grown past the event ts by more than the guard. Exported for testing.
|
|
87
|
+
export function permissionResolved(rec, mtimeMs, guard = PERM_RESOLVED_GUARD_MS) {
|
|
88
|
+
const ts = typeof rec.ts === 'number' && Number.isFinite(rec.ts) ? rec.ts : 0;
|
|
89
|
+
return typeof mtimeMs === 'number' && mtimeMs > ts + guard;
|
|
90
|
+
}
|
|
91
|
+
// Pure: once resolved, the transcript's last line tells a RESUME from an INTERRUPT. Approving (yes) or
|
|
92
|
+
// denying-with-feedback appends a tool_result / Claude's next message → Claude is working again → 进行中.
|
|
93
|
+
// Pressing ESC appends a `[Request interrupted by user…]` marker → the turn ended, back to idle → drop the
|
|
94
|
+
// pane to neutral (null). An unparseable/absent tail is treated as a resume (clears the stale 需要你 and
|
|
95
|
+
// shows active — a truncated huge last line is a big tool_result, never the tiny interrupt marker).
|
|
96
|
+
export function resolvedPermissionKind(lastLine) {
|
|
97
|
+
if (isInterruptTail(lastLine))
|
|
98
|
+
return null; // ESC → neutral
|
|
99
|
+
return { kind: 'working', msg: '' }; // approve / deny → 进行中
|
|
100
|
+
}
|
|
101
|
+
// Pure: does a transcript's last line mark a user ESC-interrupt? Matches both the plain and the
|
|
102
|
+
// "…for tool use" forms. Used to un-stick a 进行中 pane the instant the user aborts a turn.
|
|
103
|
+
export function isInterruptTail(lastLine) {
|
|
104
|
+
return typeof lastLine === 'string' && /Request interrupted by user/.test(lastLine);
|
|
105
|
+
}
|
|
106
|
+
// Pure: does a transcript's last line mark a /compact having RESOLVED — i.e. Claude wrote the command's
|
|
107
|
+
// <local-command-stdout>? A NO-OP /compact ("Not enough messages to compact") writes it IMMEDIATELY, whereas
|
|
108
|
+
// a real compaction stays silent for its whole (often 1-2min) run and writes it only at the very end — the
|
|
109
|
+
// same moment PostCompact already clears us. So a fresh stdout tail while still 压缩中 ⇒ the compaction is
|
|
110
|
+
// over (nothing to do / done) ⇒ drop the stuck state. Recognised by the system entry's subtype or the tag.
|
|
111
|
+
export function isLocalCommandStdout(lastLine) {
|
|
112
|
+
if (typeof lastLine !== 'string' || !lastLine)
|
|
113
|
+
return false;
|
|
114
|
+
try {
|
|
115
|
+
const o = recordOf(JSON.parse(lastLine));
|
|
116
|
+
if (o?.subtype === 'local_command')
|
|
117
|
+
return true;
|
|
118
|
+
const c = recordOf(o?.message)?.content;
|
|
119
|
+
if (typeof c === 'string' && /<local-command-stdout>/.test(c))
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
catch { /* not JSON → fall through to a raw tag match */ }
|
|
123
|
+
return /<local-command-stdout>/.test(lastLine);
|
|
124
|
+
}
|
|
125
|
+
// Truncate a Claude message to a notification-friendly one-liner.
|
|
126
|
+
function summarize(msg) {
|
|
127
|
+
const oneLine = typeof msg === 'string' ? msg.replace(/\s+/g, ' ').trim() : '';
|
|
128
|
+
return oneLine.length > 120 ? `${oneLine.slice(0, 117)}…` : oneLine;
|
|
129
|
+
}
|
|
130
|
+
// Read the hook's JSON state file. Tolerant of a missing / corrupt / half-written file (returns {}),
|
|
131
|
+
// never throws — the hook replaces it atomically, but a read can still land on a transient state.
|
|
132
|
+
function readStateFile(file) {
|
|
133
|
+
try {
|
|
134
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
135
|
+
const object = recordOf(parsed);
|
|
136
|
+
if (!object)
|
|
137
|
+
return {};
|
|
138
|
+
return Object.fromEntries(Object.entries(object).flatMap(([pane, value]) => {
|
|
139
|
+
const row = recordOf(value);
|
|
140
|
+
if (!row)
|
|
141
|
+
return [];
|
|
142
|
+
const payload = recordOf(row.payload) ?? {};
|
|
143
|
+
const record = {
|
|
144
|
+
ts: typeof row.ts === 'number' && Number.isFinite(row.ts) ? row.ts : 0,
|
|
145
|
+
src: typeof row.src === 'string' ? row.src : '',
|
|
146
|
+
payload,
|
|
147
|
+
};
|
|
148
|
+
if (Object.hasOwn(row, 'agent')) {
|
|
149
|
+
record.agent = typeof row.agent === 'string' && row.agent ? row.agent : null;
|
|
150
|
+
}
|
|
151
|
+
return [[pane, record]];
|
|
152
|
+
}));
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
return {};
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
function classification(value) {
|
|
159
|
+
const record = recordOf(value);
|
|
160
|
+
const kind = eventKind(record?.kind);
|
|
161
|
+
if (!kind)
|
|
162
|
+
return null;
|
|
163
|
+
return { kind, msg: typeof record?.msg === 'string' ? record.msg : '' };
|
|
164
|
+
}
|
|
165
|
+
function classifyRecord(record) {
|
|
166
|
+
if (!record.src)
|
|
167
|
+
return null;
|
|
168
|
+
// Rows predating the multi-Agent format have no `agent` and are known Claude records. An explicit
|
|
169
|
+
// unknown id is not legacy absence and must fail closed instead of borrowing Claude's classifier.
|
|
170
|
+
const agent = record.agent === undefined ? getAgent('claude') : getAgent(record.agent);
|
|
171
|
+
return classification(agent?.classify?.(record.src, record.payload));
|
|
172
|
+
}
|
|
173
|
+
function identifiablePanes(value) {
|
|
174
|
+
if (!Array.isArray(value))
|
|
175
|
+
return [];
|
|
176
|
+
return value.flatMap((candidate) => {
|
|
177
|
+
const pane = recordOf(candidate);
|
|
178
|
+
if (!pane || typeof pane.id !== 'string')
|
|
179
|
+
return [];
|
|
180
|
+
const cmd = typeof pane.cmd === 'string' ? pane.cmd
|
|
181
|
+
: typeof pane.command === 'string' ? pane.command : '';
|
|
182
|
+
return [{
|
|
183
|
+
...pane,
|
|
184
|
+
id: pane.id,
|
|
185
|
+
cmd,
|
|
186
|
+
tty: typeof pane.tty === 'string' ? pane.tty : '',
|
|
187
|
+
}];
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
function livePanes(value) {
|
|
191
|
+
return identifiablePanes(value).flatMap((pane) => (typeof pane.session === 'string' && typeof pane.window === 'string'
|
|
192
|
+
&& typeof pane.windowName === 'string'
|
|
193
|
+
? [pane] : []));
|
|
194
|
+
}
|
|
195
|
+
function managedStates(value) {
|
|
196
|
+
const object = recordOf(value);
|
|
197
|
+
if (!object)
|
|
198
|
+
return {};
|
|
199
|
+
return Object.fromEntries(Object.entries(object).flatMap(([pane, candidate]) => {
|
|
200
|
+
const state = recordOf(candidate);
|
|
201
|
+
if (!state)
|
|
202
|
+
return [];
|
|
203
|
+
return [[pane, {
|
|
204
|
+
kind: eventKind(state.kind),
|
|
205
|
+
msg: typeof state.msg === 'string' ? state.msg : '',
|
|
206
|
+
ts: typeof state.ts === 'number' && Number.isFinite(state.ts) ? state.ts : 0,
|
|
207
|
+
suppressPush: state.suppressPush === true,
|
|
208
|
+
}]];
|
|
209
|
+
}));
|
|
210
|
+
}
|
|
211
|
+
// Per-process event reader. Deps injected for testability:
|
|
212
|
+
// commands.listLivePanes() → [{ id, cmd, tty, session, window, windowName }] (one tmux call)
|
|
213
|
+
// run(cmd, args) → stdout (used to prove ambiguous version/node commands by real exe; default defaultRun)
|
|
214
|
+
// push.sendToSession(session, payload, {ttl, urgency, topic})
|
|
215
|
+
// file: the hook-maintained JSON state file (DEFAULT_STATE_FILE).
|
|
216
|
+
// The hook is the sole writer; the server reads the file fresh on every getStates and on every file
|
|
217
|
+
// change (the watcher, for push). No persisted state of our own — the file IS the persistence.
|
|
218
|
+
export function createClaudeEvents({ commands, push, codexApp = null, file = DEFAULT_STATE_FILE, now = () => Date.now(), statMtime = defaultStatMtime, readTail = defaultReadTail, run = defaultRun, onStateChange = () => { }, watch = fs.watch, mkdir = fs.mkdirSync, setTimer = setTimeout, clearTimer = (handle) => clearTimeout(handle), } = {}) {
|
|
219
|
+
// Short-lived executable-identity verdicts. Each poll still checks the foreground pid signature; the
|
|
220
|
+
// cache only avoids repeating lsof while that exact process set is unchanged.
|
|
221
|
+
const commVerdicts = new Map();
|
|
222
|
+
const lastPushed = {}; // pane → 'needs' | 'done' | null (in-process push-transition dedup, by display view)
|
|
223
|
+
// The dedup above is in-process ONLY: a restart (e.g. ./deploy.sh) wipes it while the hook's state
|
|
224
|
+
// file on disk keeps every pane's latest 需要你/已完成. Without priming, the first read after boot
|
|
225
|
+
// would see an empty dedup and re-push every resting pane — a flood of "historical" notifications on
|
|
226
|
+
// every redeploy. prime() (run from start(), before any request is served) adopts the file's current
|
|
227
|
+
// resting states as already-notified, so only transitions that happen AFTER boot push.
|
|
228
|
+
function prime() {
|
|
229
|
+
const recorded = readStateFile(file);
|
|
230
|
+
for (const [pane, r] of Object.entries(recorded)) {
|
|
231
|
+
if (r?.agent === 'codex')
|
|
232
|
+
continue; // legacy rows are never a Codex state source
|
|
233
|
+
const c = classifyRecord(r);
|
|
234
|
+
const view = c ? pushView(c.kind) : null;
|
|
235
|
+
if (view)
|
|
236
|
+
lastPushed[pane] = pushKey(view, r.ts); // resting 需要你/已完成 → treat as seen, don't replay
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
// Notify the device that a pane entered 需要你 / 已完成. Title carries the state label + session so the
|
|
240
|
+
// user can tell the two apart at a glance (the inbox shows the same label as a chip); body is the
|
|
241
|
+
// pane's one-line message, falling back to the bare label. 需要你 is high-urgency (wake the phone).
|
|
242
|
+
async function sendPush(pane, view, c, lp) {
|
|
243
|
+
const label = VIEW_LABEL[view];
|
|
244
|
+
const body = summarize(c.msg) || label;
|
|
245
|
+
const payload = { title: `${label} · ${lp.session}`, body, tag: `pane-${pane}`, data: { session: lp.session, window: lp.window, pane } };
|
|
246
|
+
// 可靠优先 TTL: a phone in Doze / briefly offline still gets the LATEST per pane when it wakes (the
|
|
247
|
+
// pane-topic collapses older ones, so no pileup). 需要你 holds for hours (you're being waited on);
|
|
248
|
+
// 已完成 ~30min (a stale "done" is less useful). A force-stopped app still can't be reached at all —
|
|
249
|
+
// that's an OS limit, not a TTL one.
|
|
250
|
+
const opts = { topic: `pane-${pane}`, ttl: view === 'needs' ? 14400 : 1800, urgency: view === 'needs' ? 'high' : 'normal' };
|
|
251
|
+
try {
|
|
252
|
+
await push?.sendToSession(lp.session, payload, opts);
|
|
253
|
+
}
|
|
254
|
+
catch { /* best effort */ }
|
|
255
|
+
}
|
|
256
|
+
async function updatePush(pane, c, ts, lp, { gone = false, suppressPush = false } = {}) {
|
|
257
|
+
const kind = c ? c.kind : null;
|
|
258
|
+
const view = pushView(kind);
|
|
259
|
+
const key = view ? pushKey(view, ts) : null;
|
|
260
|
+
if (kind === 'idle')
|
|
261
|
+
return;
|
|
262
|
+
if (gone || !view) {
|
|
263
|
+
lastPushed[pane] = null;
|
|
264
|
+
}
|
|
265
|
+
else if (suppressPush) {
|
|
266
|
+
lastPushed[pane] = key;
|
|
267
|
+
}
|
|
268
|
+
else if (lastPushed[pane] !== key && lp?.session && c) {
|
|
269
|
+
lastPushed[pane] = key;
|
|
270
|
+
await sendPush(pane, view, c, lp);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
// Agent identity is part of pane navigation, not inbox state. Resolve it from the pane's live foreground
|
|
274
|
+
// process so the phone can choose terminal/chat during the initial /panes load instead of waiting for the
|
|
275
|
+
// slower /states reconciliation pass. Work on copies because executable corroboration normalizes `cmd`.
|
|
276
|
+
async function identifyPaneAgents(panesInput = []) {
|
|
277
|
+
const panes = identifiablePanes(panesInput);
|
|
278
|
+
try {
|
|
279
|
+
await resolveVersionedComms(panes, run, commVerdicts);
|
|
280
|
+
}
|
|
281
|
+
catch { /* exact names still work */ }
|
|
282
|
+
try {
|
|
283
|
+
await resolveCodexComms(panes, run, commVerdicts);
|
|
284
|
+
}
|
|
285
|
+
catch { /* exact names still work */ }
|
|
286
|
+
return Object.fromEntries(panes.flatMap((pane) => {
|
|
287
|
+
const agent = agentForProc(pane.cmd);
|
|
288
|
+
return agent ? [[pane.id, agent.id]] : [];
|
|
289
|
+
}));
|
|
290
|
+
}
|
|
291
|
+
// Read the state file, reconcile every recorded pane against live tmux, and in ONE pass: (1) fire push
|
|
292
|
+
// for 需要你/已完成 transitions (deduped, global — independent of any caller's session filter), and
|
|
293
|
+
// (2) build the pane→state roster the inbox shows. A pane is dropped from the roster when its latest
|
|
294
|
+
// event is `end`/unclassifiable, or when tmux says it's gone or no longer running claude (hard kill /
|
|
295
|
+
// crash / Ctrl-C-out with no SessionEnd). `allowedSessions` (session NAMES, or null) scopes only the
|
|
296
|
+
// OUTPUT — push and reconciliation run over every pane. On a tmux failure we degrade: no location, no
|
|
297
|
+
// reconciliation, no push (we'd have no session name to route to), roster returned best-effort.
|
|
298
|
+
async function _getStates(allowedSessions = null) {
|
|
299
|
+
const allow = allowedSessions == null ? null : new Set(allowedSessions);
|
|
300
|
+
const recorded = readStateFile(file);
|
|
301
|
+
let live = null;
|
|
302
|
+
let managedCodex = {};
|
|
303
|
+
try {
|
|
304
|
+
if (!commands?.listLivePanes)
|
|
305
|
+
throw new Error('tmux commands unavailable');
|
|
306
|
+
const panes = livePanes(await commands.listLivePanes());
|
|
307
|
+
const agents = await identifyPaneAgents(panes);
|
|
308
|
+
for (const pane of panes) {
|
|
309
|
+
const agent = agents[pane.id];
|
|
310
|
+
if (agent)
|
|
311
|
+
pane.cmd = agent;
|
|
312
|
+
}
|
|
313
|
+
live = new Map(panes.map((pane) => [pane.id, pane]));
|
|
314
|
+
managedCodex = codexApp?.inboxStates
|
|
315
|
+
? managedStates(await codexApp.inboxStates(panes)) : {};
|
|
316
|
+
}
|
|
317
|
+
catch { /* tmux down */ }
|
|
318
|
+
const out = {};
|
|
319
|
+
for (const [pane, rec] of Object.entries(recorded)) {
|
|
320
|
+
// Older Handmux versions wrote Codex events into this Claude Hook state file. Codex is now strictly
|
|
321
|
+
// App Server-backed, so even an unmanaged pane must ignore those stale rows instead of reviving an
|
|
322
|
+
// approximate status or notification.
|
|
323
|
+
if (rec?.agent === 'codex')
|
|
324
|
+
continue;
|
|
325
|
+
const agent = rec.agent === undefined ? getAgent('claude') : getAgent(rec.agent);
|
|
326
|
+
if (!agent)
|
|
327
|
+
continue;
|
|
328
|
+
let c = classifyRecord(rec);
|
|
329
|
+
// A 需要你 the user already resolved leaves no closing hook (see PERM_RESOLVED_GUARD_MS). statMtime
|
|
330
|
+
// gates the cheap "still pending" path; only once the transcript has grown past the event do we pay the
|
|
331
|
+
// bounded tail read to tell a RESUME (approve/deny → 进行中) from an INTERRUPT (ESC → neutral present).
|
|
332
|
+
if (c && c.kind === 'permission') {
|
|
333
|
+
const tp = typeof rec.payload.transcript_path === 'string' ? rec.payload.transcript_path : null;
|
|
334
|
+
if (tp && permissionResolved(rec, statMtime(tp)))
|
|
335
|
+
c = resolvedPermissionKind(readTail(tp));
|
|
336
|
+
}
|
|
337
|
+
else if (c && c.kind === 'working') {
|
|
338
|
+
// ESC-interrupt during a turn leaves the last hook as the stale 'prompt' (working) — no Stop fires,
|
|
339
|
+
// so working would otherwise stick until WORKING_TTL_MS (2h), pinning the composer's send→stop toggle.
|
|
340
|
+
// Once the transcript has grown past the prompt event, a bounded tail read settles it: an interrupt
|
|
341
|
+
// marker → neutral (un-stick now); any other line → Claude is still producing, stay 进行中. Content is
|
|
342
|
+
// definitive so no guard window is needed; an unreadable stat/tail can't tell → keep working.
|
|
343
|
+
const tp = typeof rec.payload.transcript_path === 'string' ? rec.payload.transcript_path : null;
|
|
344
|
+
const mtime = tp ? statMtime(tp) : null;
|
|
345
|
+
if (tp && typeof mtime === 'number' && mtime > rec.ts && isInterruptTail(readTail(tp)))
|
|
346
|
+
c = null;
|
|
347
|
+
}
|
|
348
|
+
else if (c && c.kind === 'compacting') {
|
|
349
|
+
// A no-op /compact fires no PostCompact; it writes its <local-command-stdout> at once. Once the
|
|
350
|
+
// transcript has grown past the PreCompact event and its tail is that stdout, the /compact is done
|
|
351
|
+
// (nothing to compact) → drop 压缩中. A real compaction stays silent until PostCompact, so it keeps
|
|
352
|
+
// showing for its whole run. Unreadable stat/tail → can't tell → keep 压缩中 (the TTL is the backstop).
|
|
353
|
+
const tp = typeof rec.payload.transcript_path === 'string' ? rec.payload.transcript_path : null;
|
|
354
|
+
const mtime = tp ? statMtime(tp) : null;
|
|
355
|
+
if (tp && typeof mtime === 'number' && mtime > rec.ts && isLocalCommandStdout(readTail(tp)))
|
|
356
|
+
c = null;
|
|
357
|
+
}
|
|
358
|
+
const lp = live ? live.get(pane) : null;
|
|
359
|
+
// Dropped when tmux says the pane is gone or no longer running THIS agent (hard kill / crash /
|
|
360
|
+
// Ctrl-C-out with no clean-exit event). A pane keyed by a legacy entry (no agent field) → Claude.
|
|
361
|
+
const gone = live ? (!lp || !agent.procNames.includes(lp.cmd)) : false;
|
|
362
|
+
// (1) push side-effect — runs for every pane regardless of the output filter. Push fires on entry
|
|
363
|
+
// into a 需要你 (permission) / 已完成 (done) view, deduped so a stay-put doesn't re-push. The idle
|
|
364
|
+
// reminder that trails a done is a NO-OP: 已完成 pings at the completion moment (done) only, so the
|
|
365
|
+
// 60s "still waiting" idle neither pushes nor disturbs the dedup (the pane is still in the same
|
|
366
|
+
// resting state). working / end / gone / unclassifiable re-arm the dedup for the next entry.
|
|
367
|
+
if (live) {
|
|
368
|
+
await updatePush(pane, c, rec.ts, lp, { gone });
|
|
369
|
+
}
|
|
370
|
+
// (2) roster — drop ended / dead / claude-exited panes; resolve location from the live tmux row.
|
|
371
|
+
if (!c || c.kind === 'end' || gone)
|
|
372
|
+
continue;
|
|
373
|
+
// Expire a 进行中 latched past the TTL (an ESC-interrupt / walk-away that never got a Stop): drop it
|
|
374
|
+
// from the roster so the stuck working pane goes away. See WORKING_TTL_MS.
|
|
375
|
+
if (c.kind === 'working' && now() - (rec.ts || 0) > WORKING_TTL_MS)
|
|
376
|
+
continue;
|
|
377
|
+
if (c.kind === 'compacting' && now() - (rec.ts || 0) > COMPACTING_TTL_MS)
|
|
378
|
+
continue;
|
|
379
|
+
const loc = lp ? { session: lp.session, window: lp.window, windowName: lp.windowName } : {};
|
|
380
|
+
if (allow && (!lp || !allow.has(lp.session)))
|
|
381
|
+
continue;
|
|
382
|
+
out[pane] = { ...loc, kind: c.kind, msg: c.msg || '', ts: rec.ts || 0, agent: agent.id };
|
|
383
|
+
}
|
|
384
|
+
// App Server is the sole Codex state source. A neutral state still leaves process presence below to
|
|
385
|
+
// identify the pane as Codex without guessing its activity.
|
|
386
|
+
if (live) {
|
|
387
|
+
for (const [pane, state] of Object.entries(managedCodex)) {
|
|
388
|
+
const lp = live.get(pane);
|
|
389
|
+
if (!lp)
|
|
390
|
+
continue;
|
|
391
|
+
const c = state.kind
|
|
392
|
+
? { kind: state.kind, msg: state.msg } : null;
|
|
393
|
+
await updatePush(pane, c, state.ts || 0, lp, { suppressPush: state.suppressPush });
|
|
394
|
+
if (allow && !allow.has(lp.session))
|
|
395
|
+
continue;
|
|
396
|
+
out[pane] = {
|
|
397
|
+
session: lp.session, window: lp.window, windowName: lp.windowName,
|
|
398
|
+
kind: c?.kind || null, msg: c?.msg || '', ...(c ? { ts: state.ts || 0 } : {}), agent: 'codex',
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
// (3) process presence — a pane whose FOREGROUND program IS a coding agent reads as "agent here" even
|
|
403
|
+
// with no active turn on record: a fresh session that hasn't prompted yet, or one right after `/clear`
|
|
404
|
+
// (whose SessionEnd dropped its roster entry above though the agent is still running). This is what the
|
|
405
|
+
// phone's per-window agent icon and the dock's default mode key off — process liveness, not the volatile
|
|
406
|
+
// activity roster. Emitted with kind:null so the inbox (inboxRows keys off VIEW[kind]) skips it and no
|
|
407
|
+
// push fires, and with NO ts so it stays inert to the done/read high-water marks (maxTs, the seen-mark
|
|
408
|
+
// effect). An active roster entry from (2) always wins (out[id] set → skip). agentForProc matches the
|
|
409
|
+
// canonical procName (never the ambiguous 'node'), so a plain node process is not mistaken for an agent.
|
|
410
|
+
if (live) {
|
|
411
|
+
for (const lp of live.values()) {
|
|
412
|
+
if (out[lp.id])
|
|
413
|
+
continue;
|
|
414
|
+
const a = agentForProc(lp.cmd);
|
|
415
|
+
if (!a)
|
|
416
|
+
continue;
|
|
417
|
+
if (allow && !allow.has(lp.session))
|
|
418
|
+
continue;
|
|
419
|
+
out[lp.id] = { session: lp.session, window: lp.window, windowName: lp.windowName, kind: null, msg: '', agent: a.id };
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
return out;
|
|
423
|
+
}
|
|
424
|
+
// Run getStates run-to-completion: the /states poll and the file-watcher both call it, and it mutates the
|
|
425
|
+
// shared `lastPushed` dedup and `await sendPush` mid-loop — overlapping runs could interleave on that state.
|
|
426
|
+
// Serialize on a tail promise so calls queue instead of racing; each caller still gets its own filtered
|
|
427
|
+
// roster (allowedSessions differs per call), and the tmux read isn't duplicated concurrently.
|
|
428
|
+
let tail = Promise.resolve();
|
|
429
|
+
function getStates(allowedSessions = null) {
|
|
430
|
+
const current = tail.then(() => _getStates(allowedSessions), () => _getStates(allowedSessions));
|
|
431
|
+
tail = current.catch(() => { });
|
|
432
|
+
return current;
|
|
433
|
+
}
|
|
434
|
+
// Watch the state file's directory (the hook replaces the file via rename, which changes the inode, so
|
|
435
|
+
// watching the file itself would go deaf after the first write — watch the dir and filter by name).
|
|
436
|
+
// On a change, re-run getStates so 需要你/已完成 push fires even when NO client is polling — that's
|
|
437
|
+
// the whole point of push (notify you while you're away). Debounced so a burst of writes pumps once.
|
|
438
|
+
let watcher = null;
|
|
439
|
+
let deb = null;
|
|
440
|
+
function start() {
|
|
441
|
+
if (watcher)
|
|
442
|
+
return;
|
|
443
|
+
prime(); // boot baseline: don't replay the file's resting 需要你/已完成 as fresh push (see prime())
|
|
444
|
+
const dir = path.dirname(file);
|
|
445
|
+
const base = path.basename(file);
|
|
446
|
+
try {
|
|
447
|
+
mkdir(dir, { recursive: true });
|
|
448
|
+
}
|
|
449
|
+
catch { /* ignore */ }
|
|
450
|
+
try {
|
|
451
|
+
watcher = watch(dir, (_evt, fname) => {
|
|
452
|
+
if (fname && String(fname) !== base)
|
|
453
|
+
return;
|
|
454
|
+
if (deb !== null)
|
|
455
|
+
clearTimer(deb);
|
|
456
|
+
deb = setTimer(() => {
|
|
457
|
+
getStates()
|
|
458
|
+
.then(() => onStateChange())
|
|
459
|
+
.catch(() => { });
|
|
460
|
+
}, 120);
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
catch { /* fs.watch unsupported → push falls back to evaluation on each /states poll */ }
|
|
464
|
+
}
|
|
465
|
+
function stop() {
|
|
466
|
+
if (watcher) {
|
|
467
|
+
watcher.close();
|
|
468
|
+
watcher = null;
|
|
469
|
+
}
|
|
470
|
+
if (deb !== null)
|
|
471
|
+
clearTimer(deb);
|
|
472
|
+
deb = null;
|
|
473
|
+
}
|
|
474
|
+
// The chat lens's pane→session bind: the hook state file records THIS pane's exact session (session_id +
|
|
475
|
+
// transcript_path), authoritative over the terminal-side cwd→newest-jsonl guess (which collapses distinct
|
|
476
|
+
// sessions that happen to share a cwd — see transcript.js). Returns null when hooks are off / the pane
|
|
477
|
+
// isn't a Claude pane / the recorded payload carries no session info, so callers can fall back cleanly.
|
|
478
|
+
function paneSession(pane) {
|
|
479
|
+
const rec = readStateFile(file)[pane];
|
|
480
|
+
if (rec?.agent === 'codex')
|
|
481
|
+
return null;
|
|
482
|
+
const p = rec?.payload;
|
|
483
|
+
if (!p)
|
|
484
|
+
return null;
|
|
485
|
+
const transcriptPath = typeof p.transcript_path === 'string' ? p.transcript_path : null;
|
|
486
|
+
const sessionId = typeof p.session_id === 'string' ? p.session_id : null;
|
|
487
|
+
const cwd = typeof p.cwd === 'string' ? p.cwd : null;
|
|
488
|
+
if (!transcriptPath && !sessionId)
|
|
489
|
+
return null;
|
|
490
|
+
return Object.hasOwn(rec, 'agent')
|
|
491
|
+
? { sessionId, transcriptPath, cwd, agent: rec.agent ?? null }
|
|
492
|
+
: { sessionId, transcriptPath, cwd };
|
|
493
|
+
}
|
|
494
|
+
// Agent identity is needed independently of session metadata so the chat lens chooses the matching
|
|
495
|
+
// backend (Claude transcript or Codex App Server). Untagged legacy hook rows are Claude.
|
|
496
|
+
function paneAgent(pane) {
|
|
497
|
+
const rec = readStateFile(file)[pane];
|
|
498
|
+
if (!rec || typeof rec !== 'object')
|
|
499
|
+
return null;
|
|
500
|
+
// Only absence denotes a pre-multi-Agent Claude record. Any explicit unsupported/empty identity must
|
|
501
|
+
// fail closed at this legacy boundary instead of borrowing Claude's transcript controls.
|
|
502
|
+
if (rec.agent === undefined)
|
|
503
|
+
return 'claude';
|
|
504
|
+
return rec.agent === 'claude' ? 'claude' : null;
|
|
505
|
+
}
|
|
506
|
+
function paneKind(pane) {
|
|
507
|
+
const rec = readStateFile(file)[pane];
|
|
508
|
+
if (!rec || rec.agent === 'codex')
|
|
509
|
+
return null;
|
|
510
|
+
return classifyRecord(rec)?.kind ?? null;
|
|
511
|
+
}
|
|
512
|
+
function paneCompletionToken(pane) {
|
|
513
|
+
const rec = readStateFile(file)[pane];
|
|
514
|
+
if (!rec || rec.agent === 'codex')
|
|
515
|
+
return null;
|
|
516
|
+
const kind = classifyRecord(rec)?.kind ?? null;
|
|
517
|
+
if (kind !== 'done' && kind !== 'error' && kind !== 'end' && kind !== 'idle')
|
|
518
|
+
return null;
|
|
519
|
+
return Number.isFinite(rec.ts) && rec.ts >= 0 ? `claude-completed:${rec.ts}` : null;
|
|
520
|
+
}
|
|
521
|
+
return {
|
|
522
|
+
getStates, identifyPaneAgents, start, stop, paneSession, paneAgent,
|
|
523
|
+
paneKind, paneCompletionToken,
|
|
524
|
+
};
|
|
525
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { installPiExtension, PiExtensionInstallError, piExtensionStatus, uninstallPiExtension, } from './piExtension.js';
|
|
5
|
+
import { hooksHealthStatus, hooksStatus, installHooks, uninstallHooks } from './claudeHooks.js';
|
|
6
|
+
import { statusLineStatus, uninstallStatusLine } from './statusLine.js';
|
|
7
|
+
export const AGENT_NAMES = ['codex', 'pi', 'claude'];
|
|
8
|
+
const AGENT_NAME_SET = new Set(AGENT_NAMES);
|
|
9
|
+
export function agentName(value) {
|
|
10
|
+
return typeof value === 'string' && AGENT_NAME_SET.has(value) ? value : null;
|
|
11
|
+
}
|
|
12
|
+
// Resolve from PATH without running the Agent. This keeps `agent status/list` read-only and avoids startup
|
|
13
|
+
// output, prompts, config migrations, or other behavior an upstream CLI may attach to `--version`.
|
|
14
|
+
export function executableOnPath(command, env = process.env, platform = process.platform) {
|
|
15
|
+
if (!command || command.includes('/') || command.includes('\\'))
|
|
16
|
+
return false;
|
|
17
|
+
const pathValue = env.PATH;
|
|
18
|
+
if (!pathValue)
|
|
19
|
+
return false;
|
|
20
|
+
const extensions = platform === 'win32'
|
|
21
|
+
? String(env.PATHEXT || '.EXE;.CMD;.BAT;.COM').split(';').filter(Boolean)
|
|
22
|
+
: [''];
|
|
23
|
+
for (const directory of pathValue.split(path.delimiter)) {
|
|
24
|
+
if (!directory)
|
|
25
|
+
continue;
|
|
26
|
+
for (const extension of extensions) {
|
|
27
|
+
const candidate = path.join(directory, platform === 'win32' ? `${command}${extension}` : command);
|
|
28
|
+
try {
|
|
29
|
+
const stat = fs.statSync(candidate);
|
|
30
|
+
if (stat.isFile()) {
|
|
31
|
+
fs.accessSync(candidate, platform === 'win32' ? fs.constants.F_OK : fs.constants.X_OK);
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
catch { /* keep searching */ }
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
export function defaultAgentIntegrationContext({ home = homedir(), piEntryFile, hooksSrcDir, claudeStateFile, }) {
|
|
41
|
+
return {
|
|
42
|
+
home,
|
|
43
|
+
piEntryFile,
|
|
44
|
+
hooksSrcDir,
|
|
45
|
+
claudeStateFile,
|
|
46
|
+
executableAvailable: (command) => executableOnPath(command),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
export function agentIntegrationStatus(name, context) {
|
|
50
|
+
if (!context.executableAvailable(name))
|
|
51
|
+
return 'not-installed';
|
|
52
|
+
if (name === 'codex')
|
|
53
|
+
return 'ready';
|
|
54
|
+
if (name === 'claude') {
|
|
55
|
+
// PATH determines whether Claude Code itself is installed. Its first run creates ~/.claude; until then
|
|
56
|
+
// the Agent exists but the integration is not enableable, and installHooks must still never create it.
|
|
57
|
+
const status = hooksHealthStatus(context.home);
|
|
58
|
+
if (status === 'no-claude')
|
|
59
|
+
return 'not-enabled';
|
|
60
|
+
if (status === 'stale')
|
|
61
|
+
return 'needs-repair';
|
|
62
|
+
return status === 'installed' ? 'ready' : 'not-enabled';
|
|
63
|
+
}
|
|
64
|
+
let status;
|
|
65
|
+
try {
|
|
66
|
+
status = piExtensionStatus(context.home, { entryFile: context.piEntryFile });
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return 'needs-repair';
|
|
70
|
+
}
|
|
71
|
+
if (status === 'installed')
|
|
72
|
+
return 'ready';
|
|
73
|
+
if (status === 'stale')
|
|
74
|
+
return 'needs-repair';
|
|
75
|
+
if (status === 'conflict')
|
|
76
|
+
return 'conflict';
|
|
77
|
+
return 'not-enabled';
|
|
78
|
+
}
|
|
79
|
+
// Setup may offer Claude integration only after Claude itself has initialized ~/.claude. An executable on
|
|
80
|
+
// PATH without that directory is `not-enabled` for status purposes, but is not yet safely enableable.
|
|
81
|
+
export function shouldOfferClaudeIntegration(context) {
|
|
82
|
+
return agentIntegrationStatus('claude', context) === 'not-enabled'
|
|
83
|
+
&& hooksStatus(context.home) === 'absent';
|
|
84
|
+
}
|
|
85
|
+
export function enableAgentIntegration(name, context) {
|
|
86
|
+
const before = agentIntegrationStatus(name, context);
|
|
87
|
+
if (before === 'not-installed' || before === 'conflict') {
|
|
88
|
+
return { name, status: before, changed: false };
|
|
89
|
+
}
|
|
90
|
+
if (name === 'codex')
|
|
91
|
+
return { name, status: 'ready', changed: false };
|
|
92
|
+
if (name === 'claude') {
|
|
93
|
+
const installed = installHooks(context.home, {
|
|
94
|
+
srcDir: context.hooksSrcDir,
|
|
95
|
+
stateFile: context.claudeStateFile,
|
|
96
|
+
});
|
|
97
|
+
const status = agentIntegrationStatus(name, context);
|
|
98
|
+
return { name, status, changed: installed.status === 'installed' && before !== 'ready' };
|
|
99
|
+
}
|
|
100
|
+
try {
|
|
101
|
+
const result = installPiExtension(context.home, { entryFile: context.piEntryFile });
|
|
102
|
+
return { name, status: 'ready', changed: result.changed };
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
if (error instanceof PiExtensionInstallError && error.code === 'conflict') {
|
|
106
|
+
return { name, status: 'conflict', changed: false };
|
|
107
|
+
}
|
|
108
|
+
throw error;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
export function disableAgentIntegration(name, context) {
|
|
112
|
+
if (name === 'codex')
|
|
113
|
+
return { name, status: 'ready', changed: false };
|
|
114
|
+
if (name === 'claude') {
|
|
115
|
+
const changed = hooksStatus(context.home) === 'installed'
|
|
116
|
+
|| statusLineStatus(context.home) === 'ours';
|
|
117
|
+
uninstallHooks(context.home);
|
|
118
|
+
uninstallStatusLine(context.home);
|
|
119
|
+
return { name, status: 'not-enabled', changed };
|
|
120
|
+
}
|
|
121
|
+
try {
|
|
122
|
+
const result = uninstallPiExtension(context.home);
|
|
123
|
+
return { name, status: 'not-enabled', changed: result.changed };
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
if (error instanceof PiExtensionInstallError && error.code === 'conflict') {
|
|
127
|
+
return { name, status: 'conflict', changed: false };
|
|
128
|
+
}
|
|
129
|
+
throw error;
|
|
130
|
+
}
|
|
131
|
+
}
|