@hua-labs/tap 0.5.0 → 0.5.2

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 CHANGED
@@ -1,27 +1,30 @@
1
1
  # @hua-labs/tap
2
2
 
3
- Zero-dependency CLI for cross-model AI agent communication setup.
3
+ `tap` is a CLI that turns your repo into a shared workspace for Claude, Codex, and Gemini (experimental) so multiple AI agents can coordinate on the same codebase without custom glue code.
4
4
 
5
- One command to connect Claude, Codex, and Gemini agents through a shared file-based communication layer.
5
+ ## Why Would I Use It?
6
+
7
+ - You use more than one coding agent and want them to share context without copy-pasting prompts between tools.
8
+ - You want reviews, handoffs, and agent-to-agent messages to live in files inside the repo instead of hidden app state.
9
+ - You want a working multi-agent setup in minutes instead of hand-editing MCP configs and bridge processes yourself.
6
10
 
7
11
  ## Quick Start
8
12
 
9
- > `npx @hua-labs/tap` ships a bundled managed MCP server entry and runs that bundled `.mjs` with `node`. `bun` is only required when tap falls back to repo-local TypeScript sources during monorepo or local-dev workflows.
13
+ Try it in a fresh repo:
10
14
 
11
15
  ```bash
12
- # 1. Initialize comms directory and state
13
16
  npx @hua-labs/tap init
14
-
15
- # 2. Add runtimes
16
17
  npx @hua-labs/tap add claude
17
18
  npx @hua-labs/tap add codex
18
- npx @hua-labs/tap add gemini
19
-
20
- # 3. Check status
19
+ npx @hua-labs/tap add gemini # experimental
21
20
  npx @hua-labs/tap status
22
21
  ```
23
22
 
24
- Your agents can now communicate through the shared comms directory.
23
+ This creates a shared comms/state layer and wires supported runtimes into it.
24
+
25
+ Gemini support is currently experimental and polling-only.
26
+
27
+ > `npx @hua-labs/tap` ships a bundled managed MCP server entry and runs that bundled `.mjs` with `node`. `bun` is only required when tap falls back to repo-local TypeScript sources during monorepo or local-dev workflows.
25
28
 
26
29
  ## Commands
27
30
 
@@ -46,7 +49,7 @@ Add a runtime. Probes config, plans patches, applies, and verifies.
46
49
  ```bash
47
50
  npx @hua-labs/tap add claude
48
51
  npx @hua-labs/tap add codex
49
- npx @hua-labs/tap add gemini
52
+ npx @hua-labs/tap add gemini # experimental
50
53
  npx @hua-labs/tap add claude --force # re-install
51
54
  ```
52
55
 
@@ -99,7 +102,7 @@ For npm installs, `serve` runs the bundled `mcp-server.mjs` entry with `node`. I
99
102
  | ------- | ----------------------- | ---------------------- | ------------------ |
100
103
  | Claude | `.mcp.json` | native-push (fs.watch) | No daemon needed |
101
104
  | Codex | `~/.codex/config.toml` | WebSocket bridge | Daemon per session |
102
- | Gemini | `.gemini/settings.json` | polling | No daemon needed |
105
+ | Gemini (experimental) | `.gemini/settings.json` | polling | No daemon needed |
103
106
 
104
107
  ## `--json` Flag
105
108
 
@@ -194,6 +197,14 @@ The adapter contract (`RuntimeAdapter`) is the extension point for adding new ru
194
197
  - **Bundled MCP runtime** — bundled `.mjs` server entries now prefer `node`; repo-local TypeScript sources still use `bun`
195
198
  - **Hotfixes** — ESM `require()` breakage, temp file leaks in name claims, and claim-stealing edge cases were fixed during publish prep
196
199
 
200
+ ### Trust Layer And Delivery
201
+
202
+ - **Shared vs runtime state split** — `TAP_STATE_DIR` remains the shared source of truth while `TAP_RUNTIME_STATE_DIR` is reserved for per-bridge runtime files, so headless restarts and later TUI attaches keep the same identity contract
203
+ - **Attached TUI rebind** — Codex TUI attach can now recover `agentId` and `agentName` from runtime heartbeat and agent-name files without relying on per-session env injection
204
+ - **State surface alignment** — bridge status, runtime heartbeat, and presence now read from the same state surfaces, reducing mismatches between `tap status`, bridge state, and plugin-visible presence
205
+ - **Broadcast dedupe** — bridge-dispatched notifications are deduplicated so one broadcast does not fan out twice
206
+ - **Ack storm prevention** — peer DM auto-replies are rate-limited to stop acknowledgement loops from flooding the inbox
207
+
197
208
  ### Test Hardening
198
209
 
199
210
  - **CLI-path coverage** — integration tests now exercise the actual `bridge` and `up` command paths that patch Codex `approval_mode`
@@ -205,6 +216,7 @@ The adapter contract (`RuntimeAdapter`) is the extension point for adding new ru
205
216
  - **Bundled MCP command changed for packaged installs** — if your managed `config.toml` still points bundled tap MCP entries at `bun`, rerun `npx @hua-labs/tap add codex --force` or `npx @hua-labs/tap doctor --fix` so bundled `.mjs` entries switch to `node`.
206
217
  - **Repo-local source workflows still use `bun`** — local monorepo or source-checkout paths can still resolve to `.ts` server entries, so keep `bun` installed for development workflows.
207
218
  - **Codex approval mode should be `auto`** — managed Codex installs are expected to end up with `[mcp_servers.tap] approval_mode = "auto"`. `tap doctor --fix` will repair stale managed tables.
219
+ - **Restart Codex bridges after upgrading** — managed bridge launches now export both `TAP_STATE_DIR` and `TAP_RUNTIME_STATE_DIR`; restart existing bridge processes so headless/runtime identity repair is active end-to-end.
208
220
 
209
221
  ## License
210
222
 
@@ -1,4 +1,5 @@
1
1
  type BusyMode = "wait" | "steer";
2
+ type LogLevel = "debug" | "info" | "warn" | "error";
2
3
  interface Options {
3
4
  repoRoot: string;
4
5
  commsDir: string;
@@ -17,9 +18,15 @@ interface Options {
17
18
  gatewayToken: string | null;
18
19
  gatewayTokenFile: string | null;
19
20
  busyMode: BusyMode;
21
+ logLevel: LogLevel;
20
22
  threadId: string | null;
21
23
  ephemeral: boolean;
22
24
  }
25
+ interface InboxRoute {
26
+ sender: string;
27
+ recipient: string;
28
+ subject: string;
29
+ }
23
30
  interface Candidate {
24
31
  markerId: string;
25
32
  filePath: string;
@@ -37,6 +44,35 @@ interface ThreadStateRecord {
37
44
  ephemeral: boolean;
38
45
  cwd?: string | null;
39
46
  }
47
+ interface HeartbeatRecord {
48
+ pid: number;
49
+ agent: string;
50
+ updatedAt: string;
51
+ pollSeconds: number;
52
+ appServerUrl: string;
53
+ authenticated: boolean;
54
+ connected: boolean;
55
+ initialized: boolean;
56
+ threadId: string | null;
57
+ threadCwd?: string | null;
58
+ activeTurnId: string | null;
59
+ turnStartedAt: string | null;
60
+ lastTurnStatus: string | null;
61
+ lastTurnAt?: string | null;
62
+ lastDispatchAt?: string | null;
63
+ idleSince?: string | null;
64
+ turnState?: "active" | "idle" | "waiting-approval" | "disconnected";
65
+ lastNotificationMethod: string | null;
66
+ lastNotificationAt: string | null;
67
+ lastError: string | null;
68
+ lastSuccessfulAppServerAt: string | null;
69
+ lastSuccessfulAppServerMethod: string | null;
70
+ consecutiveFailureCount: number;
71
+ busyMode: BusyMode;
72
+ }
73
+ interface BridgeHealthState {
74
+ consecutiveFailureCount: number;
75
+ }
40
76
  interface HeadlessWarmupClient {
41
77
  activeTurnId: string | null;
42
78
  lastTurnStatus: string | null;
@@ -50,24 +86,219 @@ interface LoadedThreadCandidate {
50
86
  statusType: string | null;
51
87
  thread: any;
52
88
  }
89
+ interface RequestRecord {
90
+ jsonrpc: "2.0";
91
+ id: number;
92
+ method: string;
93
+ params: unknown;
94
+ }
53
95
  interface HeartbeatStoreRecord {
54
96
  id?: string;
55
97
  agent?: string;
98
+ timestamp?: string;
99
+ lastActivity?: string;
100
+ joinedAt?: string;
101
+ status?: string;
102
+ source?: "bridge-dispatch" | "mcp-direct";
103
+ instanceId?: string | null;
104
+ bridgePid?: number | null;
105
+ connectHash?: string;
56
106
  }
57
107
  type HeartbeatStore = Record<string, HeartbeatStoreRecord>;
108
+ interface JsonRpcResponse {
109
+ id?: number;
110
+ result?: any;
111
+ error?: {
112
+ code?: number;
113
+ message?: string;
114
+ data?: unknown;
115
+ };
116
+ method?: string;
117
+ params?: any;
118
+ }
119
+ declare const DEFAULT_AGENT: string;
120
+ declare const DEFAULT_APP_SERVER_URL = "ws://127.0.0.1:4501";
121
+ declare const AUTH_SUBPROTOCOL_PREFIX = "tap-auth-";
122
+ declare const PLACEHOLDER_AGENT_VALUES: Set<string>;
58
123
  declare const HEADLESS_WARMUP_PROMPT: string;
124
+ declare const HEADLESS_WARMUP_TIMEOUT_MS = 30000;
125
+ declare const TURN_COMPLETION_POLL_MS = 250;
126
+ declare const TURN_COMPLETION_REFRESH_MS = 1000;
127
+ declare const HEADLESS_SKIP_PATTERNS: RegExp[];
128
+ declare const COMMS_HEARTBEAT_LOCK_TIMEOUT_MS = 2000;
129
+ declare const COMMS_LOCK_STALE_AGE_MS = 10000;
130
+ /** M203: Timeout after which an active turn is considered stale (5 minutes). */
131
+ declare const STALE_TURN_MS: number;
132
+
133
+ /**
134
+ * M206: Re-export canonicalizeAgentId as canonicalize for backward compat.
135
+ */
136
+ declare function canonicalize(id: string): string;
137
+ declare function normalizeThreadCwd(cwd: string): string;
59
138
  declare function threadCwdMatches(expectedCwd: string, actualCwd: string | null | undefined): boolean;
60
139
  declare function chooseLoadedThreadForCwd(cwd: string, threads: LoadedThreadCandidate[]): LoadedThreadCandidate | null;
140
+ declare function normalizeAgentToken(value?: string | null): string | null;
61
141
  declare function resolveAgentId(preferredAgentName?: string | null): string;
62
- declare function loadResumableThreadState(stateDir: string, fallbackAppServerUrl: string): ThreadStateRecord | null;
142
+ declare function resolveAgentName(preferredAgentName: string | null, stateDir: string): string;
143
+ declare function resolveCurrentAgentName(agentId: string, fallbackAgentName: string, heartbeats: HeartbeatStore): string;
144
+ declare function resolveAddressLabel(address: string, heartbeats: HeartbeatStore): string;
145
+ declare function persistAgentName(stateDir: string, agentName: string): void;
146
+ declare function formatAgentLabel(agentIdOrName: string, displayName?: string | null): string;
147
+ /**
148
+ * Resolve the current display name from heartbeats and persist if changed.
149
+ * Returns the resolved name WITHOUT mutating options.agentName — callers
150
+ * should use the return value for the current scan cycle only.
151
+ * This prevents recipient matching from losing the original configured name.
152
+ */
153
+ declare function refreshAgentIdentity(options: Options, heartbeats: HeartbeatStore): string;
154
+ /**
155
+ * M206: Delegate to shared tap-identity helper.
156
+ * Kept as named export for barrel backward compatibility.
157
+ */
63
158
  declare function recipientMatchesAgent(recipient: string, agentId: string, agentName: string): boolean;
159
+ /**
160
+ * M206: Delegate to shared tap-identity helper.
161
+ * Kept as named export for barrel backward compatibility.
162
+ */
64
163
  declare function isOwnMessageSender(sender: string, agentId: string, agentName: string): boolean;
65
- declare function resolveAddressLabel(address: string, heartbeats: HeartbeatStore): string;
66
- declare function resolveCurrentAgentName(agentId: string, fallbackAgentName: string, heartbeats: HeartbeatStore): string;
164
+ /**
165
+ * M203: Check if a turn's activeFlags indicate it cannot accept steer.
166
+ * Returns true if the turn should be treated as not active.
167
+ */
168
+ declare function isTurnStuckOnApproval(activeFlags: string[]): boolean;
169
+ /**
170
+ * M203: Check if a turn has been running longer than the stale threshold.
171
+ */
172
+ declare function isTurnStale(turnStartedAt: string | null, nowMs?: number): boolean;
173
+ declare function shouldRetrySteerAsStart(error: unknown): boolean;
174
+ /**
175
+ * Parse YAML frontmatter from message content for routing.
176
+ * Returns null if no valid frontmatter found.
177
+ */
178
+ declare function parseBridgeFrontmatter(content: string): {
179
+ sender: string;
180
+ recipient: string;
181
+ subject: string;
182
+ } | null;
183
+ /**
184
+ * Strip YAML frontmatter from message content, returning only the body.
185
+ */
186
+ declare function stripBridgeFrontmatter(content: string): string;
187
+ declare function getInboxRoute(fileName: string, body?: string): InboxRoute;
188
+ declare function getInboxRouteFromFilename(fileName: string): InboxRoute;
189
+
190
+ declare function parseArgs(argv: string[]): {
191
+ repoRoot?: string;
192
+ commsDir?: string;
193
+ agentName?: string;
194
+ stateDir?: string;
195
+ pollSeconds?: number;
196
+ reconnectSeconds?: number;
197
+ messageLookbackMinutes?: number;
198
+ processExistingMessages: boolean;
199
+ dryRun: boolean;
200
+ runOnce: boolean;
201
+ waitAfterDispatchSeconds?: number;
202
+ appServerUrl?: string;
203
+ gatewayTokenFile?: string;
204
+ busyMode?: BusyMode;
205
+ logLevel?: LogLevel;
206
+ threadId?: string;
207
+ ephemeral: boolean;
208
+ };
209
+ declare function resolveRepoRoot(explicit?: string): string;
210
+ declare function resolveTapConfigPath(repoRoot: string, input: string): string;
211
+ declare function resolveCommsDir(repoRoot: string, explicit?: string): string;
212
+ declare function resolvePreferredAgentName(requested?: string): string | null;
213
+ declare function sanitizeStateSegment(agentName: string): string;
214
+ declare function buildDefaultStateDir(repoRoot: string, preferredAgentName?: string | null): string;
215
+ declare function resolveStateDir(repoRoot: string, explicit?: string, preferredAgentName?: string | null): string;
216
+ declare function readGatewayTokenFile(tokenFile: string): string;
217
+ declare function buildOptions(argv: string[]): Options;
218
+
219
+ declare function buildMarkerId(filePath: string, mtimeMs: number): string;
220
+ declare function getProcessedMarkerPath(stateDir: string, markerId: string): string;
221
+ declare function loadHeartbeats(commsDir: string): HeartbeatStore;
222
+ declare function shouldSkipInHeadlessMode(fileName: string, body: string): boolean;
223
+ declare function collectCandidates(inboxDir: string, agentId: string, agentName: string, aliasName?: string): Candidate[];
224
+ declare function getPendingCandidates(options: Options, cutoff: Date): {
225
+ heartbeats: HeartbeatStore;
226
+ candidates: Candidate[];
227
+ };
228
+
67
229
  declare function buildUserInput(candidate: Candidate, agentName: string, heartbeats: HeartbeatStore): string;
230
+ declare function writeProcessedMarker(stateDir: string, candidate: Candidate, dispatchMode: "start" | "steer", threadId: string | null, turnId: string | null): void;
231
+ declare function writeLastDispatch(stateDir: string, candidate: Candidate, dispatchMode: "start" | "steer", threadId: string | null, turnId: string | null): void;
232
+
233
+ type LogContext = Record<string, unknown>;
234
+ interface BridgeLogger {
235
+ debug(message: string, context?: LogContext): void;
236
+ info(message: string, context?: LogContext): void;
237
+ warn(message: string, context?: LogContext): void;
238
+ error(message: string, context?: LogContext): void;
239
+ }
240
+
241
+ declare function readSocketData(data: unknown): Promise<string>;
242
+ declare function formatJsonRpcError(error: JsonRpcResponse["error"]): string;
243
+ declare class AppServerClient {
244
+ private socket;
245
+ private readonly url;
246
+ private readonly gatewayToken;
247
+ private readonly logger;
248
+ private readonly clientId;
249
+ private nextId;
250
+ private pending;
251
+ connected: boolean;
252
+ initialized: boolean;
253
+ threadId: string | null;
254
+ currentThreadCwd: string | null;
255
+ activeTurnId: string | null;
256
+ turnStartedAt: string | null;
257
+ lastTurnStatus: string | null;
258
+ lastNotificationMethod: string | null;
259
+ lastNotificationAt: string | null;
260
+ lastError: string | null;
261
+ lastSuccessfulAppServerAt: string | null;
262
+ lastSuccessfulAppServerMethod: string | null;
263
+ constructor(url: string, logger: BridgeLogger, gatewayToken?: string | null);
264
+ connect(): Promise<void>;
265
+ disconnect(): Promise<void>;
266
+ ensureThread(explicitThreadId: string | null, savedThread: ThreadStateRecord | null, cwd: string, ephemeral: boolean): Promise<string>;
267
+ findLoadedThread(cwd: string): Promise<string | null>;
268
+ startTurn(inputText: string): Promise<string | null>;
269
+ steerTurn(inputText: string): Promise<string>;
270
+ isBusy(): boolean;
271
+ refreshCurrentThreadState(): Promise<void>;
272
+ private requireThreadId;
273
+ private requireActiveTurnId;
274
+ private refreshThreadState;
275
+ private syncThreadStateFromThread;
276
+ private handleMessage;
277
+ private handleNotification;
278
+ private request;
279
+ private rejectPending;
280
+ }
281
+
282
+ declare function sanitizeErrorForPersistence(error: string | null): string | null;
283
+ declare function readThreadState(stateDir: string): ThreadStateRecord | null;
284
+ declare function persistThreadState(stateDir: string, threadId: string, appServerUrl: string, ephemeral: boolean, cwd: string | null): void;
285
+ declare function acquireCommsLock(lockPath: string): boolean;
286
+ declare function releaseCommsLock(lockPath: string): void;
287
+ declare function updateCommsHeartbeat(options: Options, status: string): void;
288
+ declare function writeHeartbeat(options: Options, client: AppServerClient | null, health: BridgeHealthState): void;
289
+ declare function dispatchCandidate(client: AppServerClient, options: Options, candidate: Candidate, heartbeats: HeartbeatStore): Promise<boolean>;
290
+ declare function runScan(options: Options, cutoff: Date, client: AppServerClient | null): Promise<{
291
+ dispatched: boolean;
292
+ maxMtimeMs: number;
293
+ }>;
294
+ declare function waitForTurnDrain(options: Options, client: AppServerClient, health: BridgeHealthState): Promise<void>;
68
295
  declare function waitForTurnCompletion(client: Pick<HeadlessWarmupClient, "activeTurnId" | "lastTurnStatus" | "refreshCurrentThreadState">, turnId: string, timeoutMs: number): Promise<string | null>;
69
296
  declare function maybeBootstrapHeadlessTurn(options: Options, cutoff: Date, client: HeadlessWarmupClient): Promise<boolean>;
70
- declare function buildOptions(argv: string[]): Options;
297
+
298
+ declare function readHeartbeatState(stateDir: string): HeartbeatRecord | null;
299
+ declare function loadResumableThreadState(stateDir: string, fallbackAppServerUrl: string): ThreadStateRecord | null;
300
+ declare function getGeneralInboxCutoff(stateDir: string, lookbackMinutes: number, processExistingMessages: boolean): Date;
71
301
  declare function main(): Promise<void>;
302
+ declare function isDirectExecution(): boolean;
72
303
 
73
- export { HEADLESS_WARMUP_PROMPT, type HeadlessWarmupClient, type LoadedThreadCandidate, buildOptions, buildUserInput, chooseLoadedThreadForCwd, isOwnMessageSender, loadResumableThreadState, main, maybeBootstrapHeadlessTurn, recipientMatchesAgent, resolveAddressLabel, resolveAgentId, resolveCurrentAgentName, threadCwdMatches, waitForTurnCompletion };
304
+ export { AUTH_SUBPROTOCOL_PREFIX, AppServerClient, type BridgeHealthState, type BusyMode, COMMS_HEARTBEAT_LOCK_TIMEOUT_MS, COMMS_LOCK_STALE_AGE_MS, type Candidate, DEFAULT_AGENT, DEFAULT_APP_SERVER_URL, HEADLESS_SKIP_PATTERNS, HEADLESS_WARMUP_PROMPT, HEADLESS_WARMUP_TIMEOUT_MS, type HeadlessWarmupClient, type HeartbeatRecord, type HeartbeatStore, type HeartbeatStoreRecord, type InboxRoute, type JsonRpcResponse, type LoadedThreadCandidate, type LogLevel, type Options, PLACEHOLDER_AGENT_VALUES, type RequestRecord, STALE_TURN_MS, TURN_COMPLETION_POLL_MS, TURN_COMPLETION_REFRESH_MS, type ThreadStateRecord, acquireCommsLock, buildDefaultStateDir, buildMarkerId, buildOptions, buildUserInput, canonicalize, chooseLoadedThreadForCwd, collectCandidates, dispatchCandidate, formatAgentLabel, formatJsonRpcError, getGeneralInboxCutoff, getInboxRoute, getInboxRouteFromFilename, getPendingCandidates, getProcessedMarkerPath, isDirectExecution, isOwnMessageSender, isTurnStale, isTurnStuckOnApproval, loadHeartbeats, loadResumableThreadState, main, maybeBootstrapHeadlessTurn, normalizeAgentToken, normalizeThreadCwd, parseArgs, parseBridgeFrontmatter, persistAgentName, persistThreadState, readGatewayTokenFile, readHeartbeatState, readSocketData, readThreadState, recipientMatchesAgent, refreshAgentIdentity, releaseCommsLock, resolveAddressLabel, resolveAgentId, resolveAgentName, resolveCommsDir, resolveCurrentAgentName, resolvePreferredAgentName, resolveRepoRoot, resolveStateDir, resolveTapConfigPath, runScan, sanitizeErrorForPersistence, sanitizeStateSegment, shouldRetrySteerAsStart, shouldSkipInHeadlessMode, stripBridgeFrontmatter, threadCwdMatches, updateCommsHeartbeat, waitForTurnCompletion, waitForTurnDrain, writeHeartbeat, writeLastDispatch, writeProcessedMarker };