@dezycro-ai/agent-plugin 2.0.0 → 2.1.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 CHANGED
@@ -116,7 +116,7 @@ See **[PERSONAS.md](./PERSONAS.md)** for the full schema, all supported auth typ
116
116
 
117
117
  For installation issues, MCP setup, updates, and uninstall instructions, see the [installation guide](https://docs.dezycro.ai/claude-code/install/).
118
118
 
119
- For everything else, sign in at [app.dezycro.ai](https://app.dezycro.ai) and use the in-app support channel.
119
+ For everything else, email [support@dezycro.io](mailto:support@dezycro.io).
120
120
 
121
121
  ---
122
122
 
@@ -0,0 +1,53 @@
1
+ /**
2
+ * command-bus.ts — pulls the Agent Command Bus inbox on hook boundaries and renders
3
+ * unseen events for the agent transcript.
4
+ *
5
+ * Polling model (per the Agent Command Bus TRD):
6
+ * * `session-start` always polls (no floor).
7
+ * * `user-prompt-submit`, `stop`, `post-tool-use` poll only if `minPollIntervalSeconds`
8
+ * has elapsed since the last successful poll.
9
+ * * On 2xx: advance cursor + lastPollTs, dedup by `eventId` (recent ring, cap 200),
10
+ * return new events for surfacing.
11
+ * * On non-2xx / network failure: cursor stays put, consecutiveFailures++.
12
+ *
13
+ * Auth: a bearer token from `.dezycro/companion-token.local.json` (`{"token": "dzy_…"}`)
14
+ * is sent on every request. Separate from the MCP server token (the user maintains both).
15
+ *
16
+ * URL: `<environments[default].apiUrl>/api/v1/workspaces/<wsId>/features/<fId>/inbox`.
17
+ * Active feature comes from `.dezycro/active-feature.json` written by `/dezycro:work`.
18
+ */
19
+ import type { CommandBusConfig, CommandBusHook, CommandBusReactionMode, CompanionState, InboxEvent, StatePatch } from '../types';
20
+ export interface PollResult {
21
+ events: InboxEvent[];
22
+ statePatch: StatePatch | null;
23
+ reactionMode: CommandBusReactionMode;
24
+ }
25
+ interface ResolvedConfig {
26
+ enabled: boolean;
27
+ hooks: ReadonlySet<CommandBusHook>;
28
+ minPollIntervalSeconds: number;
29
+ reactionMode: CommandBusReactionMode;
30
+ }
31
+ /**
32
+ * Entry point called from each hook handler. Returns the events that should be surfaced
33
+ * to the agent on this hook fire, plus a state patch the caller must apply.
34
+ *
35
+ * Never throws — failures are logged and result in `{events: [], statePatch: null}`.
36
+ */
37
+ export declare function pollIfDue(repoRoot: string, hookEvent: CommandBusHook, config: CommandBusConfig | undefined, state: CompanionState): Promise<PollResult>;
38
+ /**
39
+ * Render zero or more events as a chat-ready Markdown block. Returns the empty string
40
+ * when there is nothing to surface — callers can guard with the return value's length.
41
+ */
42
+ export declare function renderForAgent(events: InboxEvent[], reactionMode: CommandBusReactionMode): string;
43
+ declare function renderEvent(e: InboxEvent): string;
44
+ declare function reactionGuidance(mode: CommandBusReactionMode): string;
45
+ declare function resolveConfig(c: CommandBusConfig | undefined): ResolvedConfig;
46
+ declare function trimRecent(ids: string[]): string[];
47
+ export declare const _internals: {
48
+ resolveConfig: typeof resolveConfig;
49
+ renderEvent: typeof renderEvent;
50
+ reactionGuidance: typeof reactionGuidance;
51
+ trimRecent: typeof trimRecent;
52
+ };
53
+ export {};
@@ -0,0 +1,307 @@
1
+ /**
2
+ * command-bus.ts — pulls the Agent Command Bus inbox on hook boundaries and renders
3
+ * unseen events for the agent transcript.
4
+ *
5
+ * Polling model (per the Agent Command Bus TRD):
6
+ * * `session-start` always polls (no floor).
7
+ * * `user-prompt-submit`, `stop`, `post-tool-use` poll only if `minPollIntervalSeconds`
8
+ * has elapsed since the last successful poll.
9
+ * * On 2xx: advance cursor + lastPollTs, dedup by `eventId` (recent ring, cap 200),
10
+ * return new events for surfacing.
11
+ * * On non-2xx / network failure: cursor stays put, consecutiveFailures++.
12
+ *
13
+ * Auth: a bearer token from `.dezycro/companion-token.local.json` (`{"token": "dzy_…"}`)
14
+ * is sent on every request. Separate from the MCP server token (the user maintains both).
15
+ *
16
+ * URL: `<environments[default].apiUrl>/api/v1/workspaces/<wsId>/features/<fId>/inbox`.
17
+ * Active feature comes from `.dezycro/active-feature.json` written by `/dezycro:work`.
18
+ */
19
+ 'use strict';
20
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
21
+ if (k2 === undefined) k2 = k;
22
+ var desc = Object.getOwnPropertyDescriptor(m, k);
23
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
24
+ desc = { enumerable: true, get: function() { return m[k]; } };
25
+ }
26
+ Object.defineProperty(o, k2, desc);
27
+ }) : (function(o, m, k, k2) {
28
+ if (k2 === undefined) k2 = k;
29
+ o[k2] = m[k];
30
+ }));
31
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
32
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
33
+ }) : function(o, v) {
34
+ o["default"] = v;
35
+ });
36
+ var __importStar = (this && this.__importStar) || (function () {
37
+ var ownKeys = function(o) {
38
+ ownKeys = Object.getOwnPropertyNames || function (o) {
39
+ var ar = [];
40
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
41
+ return ar;
42
+ };
43
+ return ownKeys(o);
44
+ };
45
+ return function (mod) {
46
+ if (mod && mod.__esModule) return mod;
47
+ var result = {};
48
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
49
+ __setModuleDefault(result, mod);
50
+ return result;
51
+ };
52
+ })();
53
+ Object.defineProperty(exports, "__esModule", { value: true });
54
+ exports._internals = void 0;
55
+ exports.pollIfDue = pollIfDue;
56
+ exports.renderForAgent = renderForAgent;
57
+ const fs = __importStar(require("fs"));
58
+ const path = __importStar(require("path"));
59
+ const logging = __importStar(require("./logging"));
60
+ const DEFAULT_HOOKS = [
61
+ 'sessionStart',
62
+ 'userPromptSubmit',
63
+ 'stop',
64
+ 'postToolUse',
65
+ ];
66
+ const DEFAULT_MIN_POLL_INTERVAL_SECONDS = 60;
67
+ const DEFAULT_REACTION_MODE = 'HALT_AND_REPLAN';
68
+ const RECENT_EVENT_IDS_CAP = 200;
69
+ const POLL_LIMIT = 100;
70
+ /**
71
+ * Entry point called from each hook handler. Returns the events that should be surfaced
72
+ * to the agent on this hook fire, plus a state patch the caller must apply.
73
+ *
74
+ * Never throws — failures are logged and result in `{events: [], statePatch: null}`.
75
+ */
76
+ async function pollIfDue(repoRoot, hookEvent, config, state) {
77
+ const resolved = resolveConfig(config);
78
+ const reactionMode = resolved.reactionMode;
79
+ const empty = { events: [], statePatch: null, reactionMode };
80
+ if (!resolved.enabled)
81
+ return empty;
82
+ if (!resolved.hooks.has(hookEvent) && hookEvent !== 'sessionStart')
83
+ return empty;
84
+ // SessionStart always polls regardless of the configured hook set — bootstrap-on-start
85
+ // is non-negotiable for cursor recovery after compaction.
86
+ const cbState = state.commandBus;
87
+ if (hookEvent !== 'sessionStart' && cbState.lastPollTs) {
88
+ const lastMs = Date.parse(cbState.lastPollTs);
89
+ if (!Number.isNaN(lastMs)) {
90
+ const ageSeconds = (Date.now() - lastMs) / 1000;
91
+ if (ageSeconds < resolved.minPollIntervalSeconds)
92
+ return empty;
93
+ }
94
+ }
95
+ const feature = readActiveFeature(repoRoot);
96
+ if (!feature)
97
+ return empty;
98
+ const apiUrl = readApiUrl(repoRoot);
99
+ if (!apiUrl)
100
+ return empty;
101
+ const token = readToken(repoRoot);
102
+ if (!token) {
103
+ logging.info(repoRoot, 'command_bus_skip_no_token', { hookEvent });
104
+ return empty;
105
+ }
106
+ const since = hookEvent === 'sessionStart' ? null : cbState.cursor;
107
+ try {
108
+ const fetched = await fetchInbox(apiUrl, feature, token, since);
109
+ const seen = new Set(cbState.recentEventIds ?? []);
110
+ const fresh = fetched.events.filter((e) => !seen.has(e.eventId));
111
+ const newRing = trimRecent([...(cbState.recentEventIds ?? []), ...fresh.map((e) => e.eventId)]);
112
+ const patch = {
113
+ commandBus: {
114
+ cursor: fetched.nextCursor || cbState.cursor,
115
+ lastPollTs: fetched.serverTs,
116
+ consecutiveFailures: 0,
117
+ recentEventIds: newRing,
118
+ },
119
+ };
120
+ if (fresh.length > 0) {
121
+ logging.info(repoRoot, 'command_bus_events_received', {
122
+ hookEvent,
123
+ count: fresh.length,
124
+ types: fresh.map((e) => e.eventType),
125
+ });
126
+ }
127
+ return { events: fresh, statePatch: patch, reactionMode };
128
+ }
129
+ catch (e) {
130
+ const patch = {
131
+ commandBus: {
132
+ ...cbState,
133
+ consecutiveFailures: (cbState.consecutiveFailures ?? 0) + 1,
134
+ },
135
+ };
136
+ logging.info(repoRoot, 'command_bus_poll_failed', {
137
+ hookEvent,
138
+ error: e instanceof Error ? e.message : String(e),
139
+ consecutiveFailures: patch.commandBus.consecutiveFailures,
140
+ });
141
+ return { events: [], statePatch: patch, reactionMode };
142
+ }
143
+ }
144
+ /**
145
+ * Render zero or more events as a chat-ready Markdown block. Returns the empty string
146
+ * when there is nothing to surface — callers can guard with the return value's length.
147
+ */
148
+ function renderForAgent(events, reactionMode) {
149
+ if (events.length === 0)
150
+ return '';
151
+ const lines = [];
152
+ lines.push(`⚠️ Dezycro Command Bus: ${events.length} new event(s) on this feature.`);
153
+ lines.push('');
154
+ for (const e of events) {
155
+ lines.push(renderEvent(e));
156
+ }
157
+ if (events.some((e) => e.urgency === 'BLOCKING')) {
158
+ lines.push('');
159
+ lines.push(reactionGuidance(reactionMode));
160
+ }
161
+ return lines.join('\n');
162
+ }
163
+ function renderEvent(e) {
164
+ if (e.eventType === 'INVALIDATION') {
165
+ const summary = stringField(e.payload, 'artifactSummary') ?? '(no summary)';
166
+ const reason = stringField(e.payload, 'reason') ?? '(no reason)';
167
+ const prev = stringField(e.payload, 'previousStatus') ?? '?';
168
+ const sourceLabel = `${e.sourceType.toLowerCase()} ${e.sourceId ?? '?'}`;
169
+ return `- INVALIDATION (${e.urgency}): ${sourceLabel} ("${truncate(summary, 120)}") — reason: "${truncate(reason, 200)}" — was ${prev}.`;
170
+ }
171
+ return `- ${e.eventType} (${e.urgency}) source=${e.sourceType}/${e.sourceId ?? '-'}.`;
172
+ }
173
+ function reactionGuidance(mode) {
174
+ switch (mode) {
175
+ case 'HALT_AND_REPLAN':
176
+ return 'Reaction mode: HALT_AND_REPLAN — pause your current line of work, acknowledge in chat, and re-plan around these invalidations before touching code again.';
177
+ case 'ACKNOWLEDGE_AND_CONTINUE':
178
+ return 'Reaction mode: ACKNOWLEDGE_AND_CONTINUE — surface this in your reply but continue current work; do not silently reuse invalidated artifacts.';
179
+ case 'SILENT_LOG':
180
+ return 'Reaction mode: SILENT_LOG — recorded for the session transcript; no chat interruption.';
181
+ default:
182
+ return '';
183
+ }
184
+ }
185
+ /* ─── Helpers ─────────────────────────────────────────────────────────── */
186
+ function resolveConfig(c) {
187
+ const enabled = c?.enabled !== false; // default true
188
+ const hooks = new Set(c?.pollHooks ?? DEFAULT_HOOKS);
189
+ hooks.add('sessionStart'); // bootstrap is non-removable
190
+ const minPollIntervalSeconds = Math.max(0, c?.minPollIntervalSeconds ?? DEFAULT_MIN_POLL_INTERVAL_SECONDS);
191
+ const reactionMode = c?.reactionMode ?? DEFAULT_REACTION_MODE;
192
+ return { enabled, hooks, minPollIntervalSeconds, reactionMode };
193
+ }
194
+ function readActiveFeature(repoRoot) {
195
+ try {
196
+ const p = path.join(repoRoot, '.dezycro', 'active-feature.json');
197
+ if (!fs.existsSync(p))
198
+ return null;
199
+ const raw = JSON.parse(fs.readFileSync(p, 'utf8'));
200
+ const featureId = typeof raw.featureId === 'string' ? raw.featureId : null;
201
+ const workspaceId = typeof raw.workspaceId === 'string' ? raw.workspaceId : null;
202
+ if (!featureId)
203
+ return null;
204
+ if (workspaceId)
205
+ return { workspaceId, featureId };
206
+ // Older active-feature.json shapes only carry featureId; fall back to config.
207
+ const wsFromConfig = readWorkspaceId(repoRoot);
208
+ if (!wsFromConfig)
209
+ return null;
210
+ return { workspaceId: wsFromConfig, featureId };
211
+ }
212
+ catch {
213
+ return null;
214
+ }
215
+ }
216
+ function readApiUrl(repoRoot) {
217
+ const cfg = readDezycroConfig(repoRoot);
218
+ if (!cfg)
219
+ return null;
220
+ const environments = Array.isArray(cfg.environments) ? cfg.environments : [];
221
+ const defaultEnv = environments.find((e) => e && e.default === true) ?? environments[0];
222
+ if (!defaultEnv)
223
+ return null;
224
+ const apiUrl = defaultEnv.apiUrl;
225
+ return typeof apiUrl === 'string' && apiUrl.length > 0 ? apiUrl.replace(/\/$/, '') : null;
226
+ }
227
+ function readWorkspaceId(repoRoot) {
228
+ const cfg = readDezycroConfig(repoRoot);
229
+ if (!cfg)
230
+ return null;
231
+ const wsId = cfg.workspaceId;
232
+ return typeof wsId === 'string' ? wsId : null;
233
+ }
234
+ function readDezycroConfig(repoRoot) {
235
+ try {
236
+ const p = path.join(repoRoot, '.dezycro', 'config.json');
237
+ if (!fs.existsSync(p))
238
+ return null;
239
+ return JSON.parse(fs.readFileSync(p, 'utf8'));
240
+ }
241
+ catch {
242
+ return null;
243
+ }
244
+ }
245
+ function readToken(repoRoot) {
246
+ try {
247
+ const p = path.join(repoRoot, '.dezycro', 'companion-token.local.json');
248
+ if (!fs.existsSync(p))
249
+ return null;
250
+ const raw = JSON.parse(fs.readFileSync(p, 'utf8'));
251
+ const token = raw.token;
252
+ return typeof token === 'string' && token.length > 0 ? token : null;
253
+ }
254
+ catch {
255
+ return null;
256
+ }
257
+ }
258
+ async function fetchInbox(apiUrl, feature, token, since) {
259
+ const url = new URL(`${apiUrl}/api/v1/workspaces/${feature.workspaceId}/features/${feature.featureId}/inbox`);
260
+ if (since)
261
+ url.searchParams.set('since', since);
262
+ url.searchParams.set('limit', String(POLL_LIMIT));
263
+ const controller = new AbortController();
264
+ const timeoutId = setTimeout(() => controller.abort(), 5000);
265
+ try {
266
+ const res = await fetch(url.toString(), {
267
+ method: 'GET',
268
+ headers: {
269
+ Authorization: `Bearer ${token}`,
270
+ Accept: 'application/json',
271
+ },
272
+ signal: controller.signal,
273
+ });
274
+ if (!res.ok) {
275
+ throw new Error(`inbox poll returned ${res.status}`);
276
+ }
277
+ const body = (await res.json());
278
+ if (!body || typeof body.serverTs !== 'string' || typeof body.nextCursor !== 'string') {
279
+ throw new Error('inbox poll response missing serverTs/nextCursor');
280
+ }
281
+ return {
282
+ serverTs: body.serverTs,
283
+ nextCursor: body.nextCursor,
284
+ events: Array.isArray(body.events) ? body.events : [],
285
+ };
286
+ }
287
+ finally {
288
+ clearTimeout(timeoutId);
289
+ }
290
+ }
291
+ function trimRecent(ids) {
292
+ return ids.length <= RECENT_EVENT_IDS_CAP ? ids : ids.slice(ids.length - RECENT_EVENT_IDS_CAP);
293
+ }
294
+ function stringField(payload, key) {
295
+ const v = payload?.[key];
296
+ return typeof v === 'string' ? v : null;
297
+ }
298
+ function truncate(s, max) {
299
+ return s.length <= max ? s : s.slice(0, max - 1) + '…';
300
+ }
301
+ /* Exported for tests. */
302
+ exports._internals = {
303
+ resolveConfig,
304
+ renderEvent,
305
+ reactionGuidance,
306
+ trimRecent,
307
+ };
@@ -11,11 +11,11 @@ import type { PostToolUsePayload, PreToolUsePayload, RouterInvocation, StopPaylo
11
11
  export declare const QUALITY_GATE_REASON_PREFIX = "TRD quality gate (deterministic layer) blocked APPROVED transition: ";
12
12
  export declare function dispatch(invocation: RouterInvocation): Promise<number>;
13
13
  declare function handleUpdateTaskGate(payload: PreToolUsePayload): number;
14
- declare function handlePostEdit(payload: PostToolUsePayload): number;
14
+ declare function handlePostEdit(payload: PostToolUsePayload): Promise<number>;
15
15
  declare function extractEditedPaths(toolInput: Record<string, unknown> | null | undefined): string[];
16
- declare function handleSessionStart(_payload: SessionStartPayload): number;
17
- declare function handleUserPromptSubmit(payload: UserPromptSubmitPayload): number;
18
- declare function handleStop(payload: StopPayload): number;
16
+ declare function handleSessionStart(_payload: SessionStartPayload): Promise<number>;
17
+ declare function handleUserPromptSubmit(payload: UserPromptSubmitPayload): Promise<number>;
18
+ declare function handleStop(payload: StopPayload): Promise<number>;
19
19
  declare function transcriptCharsSinceLastStop(payload: StopPayload): number | null;
20
20
  declare function readActiveFeatureId(repoRoot: string): string | null;
21
21
  declare function handleWorkbookMutation(payload: PostToolUsePayload): number;
@@ -57,6 +57,7 @@ const hashing = __importStar(require("./hashing"));
57
57
  const workbook = __importStar(require("./workbook"));
58
58
  const undo = __importStar(require("./undo"));
59
59
  const coverage = __importStar(require("./coverage"));
60
+ const commandBus = __importStar(require("./command-bus"));
60
61
  const DONE_STATUSES = new Set(['DONE', 'PUSHED']);
61
62
  const WRAP_PHRASES = [
62
63
  '/dezycro:sync',
@@ -239,10 +240,31 @@ function handleChangeDocumentStatus(payload) {
239
240
  return 0;
240
241
  }
241
242
  /* ─── Pillars 1+2 — post-edit pulse + controller-match (TRD §5.1+§5.2) ─── */
242
- function handlePostEdit(payload) {
243
+ async function handlePostEdit(payload) {
243
244
  const repoRoot = config.findRepoRoot(process.cwd());
244
245
  if (!repoRoot)
245
246
  return 0;
247
+ // Autonomous-loop poll for the Agent Command Bus inbox (TRD §Pillar 1 PostToolUse).
248
+ // Subject to the floor, so most edits no-op. When events arrive, surface them as
249
+ // additionalContext and short-circuit — pulse/spec-publish logic skips this turn.
250
+ {
251
+ const cfg = safeLoadConfig(repoRoot);
252
+ const cur = state.read(repoRoot);
253
+ const result = await commandBus.pollIfDue(repoRoot, 'postToolUse', cfg?.companion?.commandBus, cur);
254
+ if (result.statePatch)
255
+ state.patch(repoRoot, result.statePatch);
256
+ const block = commandBus.renderForAgent(result.events, result.reactionMode);
257
+ if (block) {
258
+ const out = {
259
+ hookSpecificOutput: {
260
+ hookEventName: 'PostToolUse',
261
+ additionalContext: block,
262
+ },
263
+ };
264
+ process.stdout.write(JSON.stringify(out));
265
+ return 0;
266
+ }
267
+ }
246
268
  const toolInput = (payload && payload.tool_input) || {};
247
269
  const editedPaths = extractEditedPaths(toolInput);
248
270
  if (editedPaths.length === 0)
@@ -406,7 +428,7 @@ function handleAuthoringState(flags) {
406
428
  }
407
429
  /* ─── Pillar 3 — Proactive Workbook Updates (TRD §5.3) ────────────────────── */
408
430
  const TRIVIAL_TURN_CHAR_DEFAULT = 600;
409
- function handleSessionStart(_payload) {
431
+ async function handleSessionStart(_payload) {
410
432
  const repoRoot = config.findRepoRoot(process.cwd());
411
433
  if (!repoRoot)
412
434
  return 0;
@@ -418,9 +440,16 @@ function handleSessionStart(_payload) {
418
440
  coverageFetchPending: false,
419
441
  });
420
442
  logging.info(repoRoot, 'session_start', {});
443
+ // Bootstrap-on-start poll for the Agent Command Bus inbox. Bypasses the floor.
444
+ // Failures swallowed; fetched events surface on the next UserPromptSubmit.
445
+ const cfg = safeLoadConfig(repoRoot);
446
+ const current = state.read(repoRoot);
447
+ const result = await commandBus.pollIfDue(repoRoot, 'sessionStart', cfg?.companion?.commandBus, current);
448
+ if (result.statePatch)
449
+ state.patch(repoRoot, result.statePatch);
421
450
  return 0;
422
451
  }
423
- function handleUserPromptSubmit(payload) {
452
+ async function handleUserPromptSubmit(payload) {
424
453
  const repoRoot = config.findRepoRoot(process.cwd());
425
454
  if (!repoRoot)
426
455
  return 0;
@@ -430,6 +459,27 @@ function handleUserPromptSubmit(payload) {
430
459
  currentTurnId: nextTurnId,
431
460
  turnAutoLogCount: 0,
432
461
  });
462
+ // Pre-reasoning poll for the Agent Command Bus inbox. If new events arrived
463
+ // since the last poll, surface them as additionalContext so the agent factors
464
+ // them in before responding. Wins over the existing undo / coverage paths
465
+ // for this turn — invalidations are blocking by nature.
466
+ {
467
+ const cfg = safeLoadConfig(repoRoot);
468
+ const result = await commandBus.pollIfDue(repoRoot, 'userPromptSubmit', cfg?.companion?.commandBus, current);
469
+ if (result.statePatch)
470
+ state.patch(repoRoot, result.statePatch);
471
+ const block = commandBus.renderForAgent(result.events, result.reactionMode);
472
+ if (block) {
473
+ const out = {
474
+ hookSpecificOutput: {
475
+ hookEventName: 'UserPromptSubmit',
476
+ additionalContext: block,
477
+ },
478
+ };
479
+ process.stdout.write(JSON.stringify(out));
480
+ return 0;
481
+ }
482
+ }
433
483
  const userMessage = String(payload?.prompt ?? payload?.user_message ?? payload?.message ?? '').trim();
434
484
  const wrap = detectWrapPhrase(userMessage);
435
485
  if (wrap) {
@@ -487,11 +537,26 @@ function maybeSurfaceCoverage(repoRoot, current) {
487
537
  };
488
538
  process.stdout.write(JSON.stringify(out));
489
539
  }
490
- function handleStop(payload) {
540
+ async function handleStop(payload) {
491
541
  const repoRoot = config.findRepoRoot(process.cwd());
492
542
  if (!repoRoot)
493
543
  return 0;
494
544
  const cfg = safeLoadConfig(repoRoot);
545
+ // Between-turn poll for the Agent Command Bus inbox (TRD §Pillar 1 Stop). Subject to
546
+ // the floor. When events arrive, surface as a top-level systemMessage (Stop has no
547
+ // hookSpecificOutput) and short-circuit — workbook sweep skips this turn.
548
+ {
549
+ const cur0 = state.read(repoRoot);
550
+ const result = await commandBus.pollIfDue(repoRoot, 'stop', cfg?.companion?.commandBus, cur0);
551
+ if (result.statePatch)
552
+ state.patch(repoRoot, result.statePatch);
553
+ const block = commandBus.renderForAgent(result.events, result.reactionMode);
554
+ if (block) {
555
+ const out = { systemMessage: block };
556
+ process.stdout.write(JSON.stringify(out));
557
+ return 0;
558
+ }
559
+ }
495
560
  const ws = cfg?.companion?.autoWorkbookUpdates || {};
496
561
  if (cfg?.companion?.safeMode === true)
497
562
  return silentExit();
@@ -93,6 +93,12 @@ exports.DEFAULT_COMPANION_STATE = Object.freeze({
93
93
  pendingWorkbookSweep: false,
94
94
  pendingWorkbookSweepReason: null,
95
95
  lastSeenVerifyCompletionTs: null,
96
+ commandBus: {
97
+ cursor: null,
98
+ lastPollTs: null,
99
+ consecutiveFailures: 0,
100
+ recentEventIds: [],
101
+ },
96
102
  metrics: exports.DEFAULT_COMPANION_METRICS,
97
103
  });
98
104
  function statePath(repoRoot) {
@@ -139,6 +139,7 @@ export interface CompanionState {
139
139
  pendingWorkbookSweep: boolean;
140
140
  pendingWorkbookSweepReason: string | null;
141
141
  lastSeenVerifyCompletionTs: string | null;
142
+ commandBus: CommandBusState;
142
143
  metrics: CompanionMetrics;
143
144
  }
144
145
  /**
@@ -198,12 +199,40 @@ export interface AuthoringConfig {
198
199
  enabled?: boolean;
199
200
  };
200
201
  }
202
+ export type CommandBusHook = 'sessionStart' | 'userPromptSubmit' | 'stop' | 'postToolUse';
203
+ export type CommandBusReactionMode = 'HALT_AND_REPLAN' | 'ACKNOWLEDGE_AND_CONTINUE' | 'SILENT_LOG';
204
+ export interface CommandBusConfig {
205
+ enabled?: boolean;
206
+ pollHooks?: CommandBusHook[];
207
+ minPollIntervalSeconds?: number;
208
+ reactionMode?: CommandBusReactionMode;
209
+ }
210
+ export interface CommandBusState {
211
+ cursor: string | null;
212
+ lastPollTs: string | null;
213
+ consecutiveFailures: number;
214
+ recentEventIds: string[];
215
+ }
216
+ export interface InboxEvent {
217
+ eventId: string;
218
+ featureId: string;
219
+ eventType: string;
220
+ sourceType: string;
221
+ sourceId: string | null;
222
+ urgency: 'BLOCKING' | 'INFORMATIONAL';
223
+ payload: Record<string, unknown>;
224
+ createdTs: string;
225
+ ttlAt: string;
226
+ issuerType: string;
227
+ issuerId: string | null;
228
+ }
201
229
  export interface CompanionConfig {
202
230
  companion?: {
203
231
  autoVerify?: AutoVerifyConfig;
204
232
  autoWorkbookUpdates?: AutoWorkbookUpdatesConfig;
205
233
  coverageNudges?: CoverageNudgesConfig;
206
234
  authoring?: AuthoringConfig;
235
+ commandBus?: CommandBusConfig;
207
236
  safeMode?: boolean;
208
237
  };
209
238
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dezycro-ai/agent-plugin",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "description": "Official Dezycro plugin for AI coding agents (Claude Code today, more to come) — reverse-engineer your codebase into features, PRDs, TRDs, and live verification.",
5
5
  "bin": {
6
6
  "dezycro-setup": "./setup.js",
@@ -86,7 +86,19 @@ If yes, ask for:
86
86
  - API URL (e.g., `https://api.dev.example.com`)
87
87
  - Health check endpoint (default: same as local)
88
88
 
89
- ## Step 6: Write Config
89
+ ## Step 6: Agent Command Bus Reaction Mode
90
+
91
+ The Companion can poll Dezycro's per-feature Agent Command Bus inbox so the platform can deliver in-turn commands (today: invalidations from the workbook UI; future: recap, refocus, plan-check, …) to the agent. Ask the user how the agent should react when a **blocking** event arrives mid-feature:
92
+
93
+ > When the platform sends a blocking event (e.g., a user invalidates an assumption in the workbook), how should the agent react?
94
+ >
95
+ > 1. **Halt and re-plan** (recommended) — pause current work at the next hook boundary, acknowledge in chat, re-plan before touching code again.
96
+ > 2. **Acknowledge and keep going** — surface the event in the next reply, continue current work, do not silently reuse invalidated artifacts.
97
+ > 3. **Silent log only** — record in transcript without interrupting chat. Not recommended for v1 invalidations.
98
+
99
+ Capture the choice as one of `HALT_AND_REPLAN`, `ACKNOWLEDGE_AND_CONTINUE`, `SILENT_LOG`. If the user does not pick, default to `HALT_AND_REPLAN`.
100
+
101
+ ## Step 7: Write Config
90
102
 
91
103
  Create the `.dezycro/` directory and write `.dezycro/config.json`:
92
104
 
@@ -103,6 +115,14 @@ Create the `.dezycro/` directory and write `.dezycro/config.json`:
103
115
  "healthCheck": "curl -sf <apiUrl><healthPath>",
104
116
  "default": true
105
117
  }
118
+ },
119
+ "companion": {
120
+ "commandBus": {
121
+ "enabled": true,
122
+ "pollHooks": ["sessionStart", "userPromptSubmit", "stop", "postToolUse"],
123
+ "minPollIntervalSeconds": 60,
124
+ "reactionMode": "<HALT_AND_REPLAN | ACKNOWLEDGE_AND_CONTINUE | SILENT_LOG>"
125
+ }
106
126
  }
107
127
  }
108
128
  ```
@@ -112,16 +132,36 @@ Omit `setup` and `teardown` keys if the user skipped environment setup. Include
112
132
  Use the Bash tool to create the directory: `mkdir -p .dezycro`
113
133
  Use the Write tool to write the config file.
114
134
 
115
- ## Step 7: Gitignore Check
135
+ ## Step 8: Companion API Token
136
+
137
+ The Companion plugin needs a bearer token to read the Agent Command Bus inbox directly from the platform. This is **separate from the MCP server token** (the MCP token is configured in Claude Code's MCP settings; this token is read locally by the runner). Yes, that means setting the token in two places — by design, since the runner runs outside of MCP.
138
+
139
+ Tell the user:
140
+
141
+ > Create `.dezycro/companion-token.local.json` and paste your Dezycro API token. Get one from **Dezycro > Settings > API Keys** (same key you can use for MCP). Shape:
142
+ >
143
+ > ```json
144
+ > { "token": "dzy_…" }
145
+ > ```
146
+ >
147
+ > **Always gitignore this file** — it contains a real secret.
148
+
149
+ Without this file the Companion silently skips command-bus polling (no errors, no surfaced events). You can always add it later.
150
+
151
+ ## Step 9: Gitignore Check
116
152
 
117
153
  Read `.gitignore` (if it exists) and check if it already covers `.dezycro/`. If not, suggest adding:
118
154
 
119
155
  ```
120
156
  # Dezycro local state
121
157
  .dezycro/active-feature.json
158
+ .dezycro/companion-state.json
159
+ .dezycro/companion-token.local.json
122
160
  .dezycro/bin/
123
161
  ```
124
162
 
163
+ `.dezycro/companion-token.local.json` MUST be gitignored — it holds a real API token.
164
+
125
165
  Ask the user if they want `.dezycro/config.json` committed (recommended) or gitignored too. If they want it gitignored, add `.dezycro/` instead.
126
166
 
127
167
  ## Done