@dezycro-ai/agent-plugin 2.1.0 → 2.1.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.
@@ -8,7 +8,11 @@
8
8
  * has elapsed since the last successful poll.
9
9
  * * On 2xx: advance cursor + lastPollTs, dedup by `eventId` (recent ring, cap 200),
10
10
  * return new events for surfacing.
11
- * * On non-2xx / network failure: cursor stays put, consecutiveFailures++.
11
+ * * On non-2xx / network failure: cursor stays put, consecutiveFailures++, and
12
+ * `lastAttemptTs` advances so the floor throttles repeated failures. Once
13
+ * failures cross `circuitBreakerThreshold` the floor grows exponentially
14
+ * (capped at `maxBackoffSeconds`), so an unreachable endpoint is not polled on
15
+ * every hook fire. A single success resets the counter and the interval.
12
16
  *
13
17
  * Auth: a bearer token from `.dezycro/companion-token.local.json` (`{"token": "dzy_…"}`)
14
18
  * is sent on every request. Separate from the MCP server token (the user maintains both).
@@ -27,7 +31,15 @@ interface ResolvedConfig {
27
31
  hooks: ReadonlySet<CommandBusHook>;
28
32
  minPollIntervalSeconds: number;
29
33
  reactionMode: CommandBusReactionMode;
34
+ circuitBreakerThreshold: number;
35
+ maxBackoffSeconds: number;
30
36
  }
37
+ /**
38
+ * Effective poll interval given the number of consecutive failures so far.
39
+ * Returns the base interval until `threshold` failures, then doubles per extra
40
+ * failure, capped at `maxSeconds`. Never throws.
41
+ */
42
+ export declare function backoffSeconds(baseSeconds: number, consecutiveFailures: number, threshold: number, maxSeconds: number): number;
31
43
  /**
32
44
  * Entry point called from each hook handler. Returns the events that should be surfaced
33
45
  * to the agent on this hook fire, plus a state patch the caller must apply.
@@ -49,5 +61,6 @@ export declare const _internals: {
49
61
  renderEvent: typeof renderEvent;
50
62
  reactionGuidance: typeof reactionGuidance;
51
63
  trimRecent: typeof trimRecent;
64
+ backoffSeconds: typeof backoffSeconds;
52
65
  };
53
66
  export {};
@@ -8,7 +8,11 @@
8
8
  * has elapsed since the last successful poll.
9
9
  * * On 2xx: advance cursor + lastPollTs, dedup by `eventId` (recent ring, cap 200),
10
10
  * return new events for surfacing.
11
- * * On non-2xx / network failure: cursor stays put, consecutiveFailures++.
11
+ * * On non-2xx / network failure: cursor stays put, consecutiveFailures++, and
12
+ * `lastAttemptTs` advances so the floor throttles repeated failures. Once
13
+ * failures cross `circuitBreakerThreshold` the floor grows exponentially
14
+ * (capped at `maxBackoffSeconds`), so an unreachable endpoint is not polled on
15
+ * every hook fire. A single success resets the counter and the interval.
12
16
  *
13
17
  * Auth: a bearer token from `.dezycro/companion-token.local.json` (`{"token": "dzy_…"}`)
14
18
  * is sent on every request. Separate from the MCP server token (the user maintains both).
@@ -52,9 +56,11 @@ var __importStar = (this && this.__importStar) || (function () {
52
56
  })();
53
57
  Object.defineProperty(exports, "__esModule", { value: true });
54
58
  exports._internals = void 0;
59
+ exports.backoffSeconds = backoffSeconds;
55
60
  exports.pollIfDue = pollIfDue;
56
61
  exports.renderForAgent = renderForAgent;
57
62
  const fs = __importStar(require("fs"));
63
+ const os = __importStar(require("os"));
58
64
  const path = __importStar(require("path"));
59
65
  const logging = __importStar(require("./logging"));
60
66
  const DEFAULT_HOOKS = [
@@ -67,6 +73,24 @@ const DEFAULT_MIN_POLL_INTERVAL_SECONDS = 60;
67
73
  const DEFAULT_REACTION_MODE = 'HALT_AND_REPLAN';
68
74
  const RECENT_EVENT_IDS_CAP = 200;
69
75
  const POLL_LIMIT = 100;
76
+ // After this many consecutive failures the poll interval starts doubling per
77
+ // extra failure (capped at maxBackoffSeconds) so an unreachable inbox endpoint
78
+ // — e.g. a default `local` env that isn't running — stops being polled on every
79
+ // hook fire. Self-heals: a single success resets consecutiveFailures to 0.
80
+ const DEFAULT_CIRCUIT_BREAKER_THRESHOLD = 3;
81
+ const DEFAULT_MAX_BACKOFF_SECONDS = 1800;
82
+ /**
83
+ * Effective poll interval given the number of consecutive failures so far.
84
+ * Returns the base interval until `threshold` failures, then doubles per extra
85
+ * failure, capped at `maxSeconds`. Never throws.
86
+ */
87
+ function backoffSeconds(baseSeconds, consecutiveFailures, threshold, maxSeconds) {
88
+ if (consecutiveFailures < threshold)
89
+ return baseSeconds;
90
+ const extra = consecutiveFailures - threshold + 1;
91
+ const scaled = baseSeconds * Math.pow(2, extra);
92
+ return Math.min(scaled, maxSeconds);
93
+ }
70
94
  /**
71
95
  * Entry point called from each hook handler. Returns the events that should be surfaced
72
96
  * to the agent on this hook fire, plus a state patch the caller must apply.
@@ -84,11 +108,17 @@ async function pollIfDue(repoRoot, hookEvent, config, state) {
84
108
  // SessionStart always polls regardless of the configured hook set — bootstrap-on-start
85
109
  // is non-negotiable for cursor recovery after compaction.
86
110
  const cbState = state.commandBus;
87
- if (hookEvent !== 'sessionStart' && cbState.lastPollTs) {
88
- const lastMs = Date.parse(cbState.lastPollTs);
111
+ // Throttle from the last ATTEMPT (success or failure), not just the last
112
+ // success otherwise a never-succeeding endpoint (lastPollTs stays null) is
113
+ // polled on every hook fire. The floor grows via exponential backoff once
114
+ // consecutive failures cross the circuit-breaker threshold.
115
+ const lastAttempt = cbState.lastAttemptTs ?? cbState.lastPollTs;
116
+ if (hookEvent !== 'sessionStart' && lastAttempt) {
117
+ const lastMs = Date.parse(lastAttempt);
89
118
  if (!Number.isNaN(lastMs)) {
119
+ const floorSeconds = backoffSeconds(resolved.minPollIntervalSeconds, cbState.consecutiveFailures ?? 0, resolved.circuitBreakerThreshold, resolved.maxBackoffSeconds);
90
120
  const ageSeconds = (Date.now() - lastMs) / 1000;
91
- if (ageSeconds < resolved.minPollIntervalSeconds)
121
+ if (ageSeconds < floorSeconds)
92
122
  return empty;
93
123
  }
94
124
  }
@@ -104,6 +134,7 @@ async function pollIfDue(repoRoot, hookEvent, config, state) {
104
134
  return empty;
105
135
  }
106
136
  const since = hookEvent === 'sessionStart' ? null : cbState.cursor;
137
+ const attemptTs = new Date().toISOString();
107
138
  try {
108
139
  const fetched = await fetchInbox(apiUrl, feature, token, since);
109
140
  const seen = new Set(cbState.recentEventIds ?? []);
@@ -113,6 +144,7 @@ async function pollIfDue(repoRoot, hookEvent, config, state) {
113
144
  commandBus: {
114
145
  cursor: fetched.nextCursor || cbState.cursor,
115
146
  lastPollTs: fetched.serverTs,
147
+ lastAttemptTs: attemptTs,
116
148
  consecutiveFailures: 0,
117
149
  recentEventIds: newRing,
118
150
  },
@@ -127,16 +159,21 @@ async function pollIfDue(repoRoot, hookEvent, config, state) {
127
159
  return { events: fresh, statePatch: patch, reactionMode };
128
160
  }
129
161
  catch (e) {
162
+ const consecutiveFailures = (cbState.consecutiveFailures ?? 0) + 1;
130
163
  const patch = {
131
164
  commandBus: {
132
165
  ...cbState,
133
- consecutiveFailures: (cbState.consecutiveFailures ?? 0) + 1,
166
+ lastAttemptTs: attemptTs,
167
+ consecutiveFailures,
134
168
  },
135
169
  };
170
+ const nextFloor = backoffSeconds(resolved.minPollIntervalSeconds, consecutiveFailures, resolved.circuitBreakerThreshold, resolved.maxBackoffSeconds);
136
171
  logging.info(repoRoot, 'command_bus_poll_failed', {
137
172
  hookEvent,
138
173
  error: e instanceof Error ? e.message : String(e),
139
- consecutiveFailures: patch.commandBus.consecutiveFailures,
174
+ consecutiveFailures,
175
+ nextPollAfterSeconds: nextFloor,
176
+ backoff: consecutiveFailures >= resolved.circuitBreakerThreshold,
140
177
  });
141
178
  return { events: [], statePatch: patch, reactionMode };
142
179
  }
@@ -189,7 +226,9 @@ function resolveConfig(c) {
189
226
  hooks.add('sessionStart'); // bootstrap is non-removable
190
227
  const minPollIntervalSeconds = Math.max(0, c?.minPollIntervalSeconds ?? DEFAULT_MIN_POLL_INTERVAL_SECONDS);
191
228
  const reactionMode = c?.reactionMode ?? DEFAULT_REACTION_MODE;
192
- return { enabled, hooks, minPollIntervalSeconds, reactionMode };
229
+ const circuitBreakerThreshold = Math.max(1, c?.circuitBreakerThreshold ?? DEFAULT_CIRCUIT_BREAKER_THRESHOLD);
230
+ const maxBackoffSeconds = Math.max(minPollIntervalSeconds, c?.maxBackoffSeconds ?? DEFAULT_MAX_BACKOFF_SECONDS);
231
+ return { enabled, hooks, minPollIntervalSeconds, reactionMode, circuitBreakerThreshold, maxBackoffSeconds };
193
232
  }
194
233
  function readActiveFeature(repoRoot) {
195
234
  try {
@@ -243,17 +282,27 @@ function readDezycroConfig(repoRoot) {
243
282
  }
244
283
  }
245
284
  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;
285
+ // Project-local override first, then the user-global token written by
286
+ // `dezycro-setup` (`~/.dezycro/token.json`). Typical install needs no
287
+ // per-repo file at all.
288
+ const candidates = [
289
+ path.join(repoRoot, '.dezycro', 'companion-token.local.json'),
290
+ path.join(os.homedir(), '.dezycro', 'token.json'),
291
+ ];
292
+ for (const p of candidates) {
293
+ try {
294
+ if (!fs.existsSync(p))
295
+ continue;
296
+ const raw = JSON.parse(fs.readFileSync(p, 'utf8'));
297
+ const token = raw.token;
298
+ if (typeof token === 'string' && token.length > 0)
299
+ return token;
300
+ }
301
+ catch {
302
+ // Try the next candidate.
303
+ }
256
304
  }
305
+ return null;
257
306
  }
258
307
  async function fetchInbox(apiUrl, feature, token, since) {
259
308
  const url = new URL(`${apiUrl}/api/v1/workspaces/${feature.workspaceId}/features/${feature.featureId}/inbox`);
@@ -304,4 +353,5 @@ exports._internals = {
304
353
  renderEvent,
305
354
  reactionGuidance,
306
355
  trimRecent,
356
+ backoffSeconds,
307
357
  };
@@ -50,6 +50,7 @@ const config = __importStar(require("./config"));
50
50
  const state = __importStar(require("./state"));
51
51
  const logging = __importStar(require("./logging"));
52
52
  const verify = __importStar(require("./verify"));
53
+ const publishSpec = __importStar(require("./publish-spec"));
53
54
  const controllerMatch = __importStar(require("./controller-match"));
54
55
  const authoring = __importStar(require("./authoring"));
55
56
  const qualityGate = __importStar(require("./quality-gate"));
@@ -509,10 +510,57 @@ async function handleUserPromptSubmit(payload) {
509
510
  }
510
511
  }
511
512
  }
513
+ // Pillar 2 — persistent publish-spec/verify reminder. The PostToolUse pulse
514
+ // nudge only fires on the exact edit that touches a controller path (and only
515
+ // once pulseCount crosses the threshold), so endpoint changes routinely go
516
+ // un-surfaced. Re-surface on every UserPromptSubmit while needsSpecPublish is
517
+ // set, so the agent is reminded each turn until it publishes the spec. Gated
518
+ // by companion.autoVerify.autoPublishSpecOnControllerChange (default true).
519
+ {
520
+ const specNudge = buildPublishSpecNudge(repoRoot);
521
+ if (specNudge) {
522
+ logging.info(repoRoot, 'publish_spec_nudge', { paths: specNudge.count });
523
+ const out = {
524
+ hookSpecificOutput: {
525
+ hookEventName: 'UserPromptSubmit',
526
+ additionalContext: specNudge.text,
527
+ },
528
+ };
529
+ process.stdout.write(JSON.stringify(out));
530
+ return 0;
531
+ }
532
+ }
512
533
  maybeSurfaceCoverage(repoRoot, current);
513
534
  process.stdout.write('{}');
514
535
  return 0;
515
536
  }
537
+ /**
538
+ * Build the persistent publish-spec/verify reminder, or null when nothing is
539
+ * pending or the feature is disabled. Surfaced on every UserPromptSubmit while
540
+ * `needsSpecPublish` is set; clears automatically once `/dezycro:publish-spec`
541
+ * resets the flags.
542
+ */
543
+ function buildPublishSpecNudge(repoRoot) {
544
+ const cfg = safeLoadConfig(repoRoot);
545
+ const av = (cfg && cfg.companion && cfg.companion.autoVerify) || {};
546
+ if (av.autoPublishSpecOnControllerChange === false)
547
+ return null;
548
+ const check = publishSpec.needsPublishBeforeVerify(repoRoot);
549
+ if (!check.needed || check.controllerPaths.length === 0)
550
+ return null;
551
+ const MAX_LISTED = 8;
552
+ const paths = check.controllerPaths;
553
+ const shown = paths.slice(0, MAX_LISTED).map((p) => ' - ' + p);
554
+ if (paths.length > MAX_LISTED)
555
+ shown.push(' - …and ' + (paths.length - MAX_LISTED) + ' more');
556
+ const text = '[Dezycro Companion] ' + paths.length + ' controller/API file(s) changed since the last published spec:\n' +
557
+ shown.join('\n') + '\n\n' +
558
+ 'New or changed endpoints are NOT yet reflected in the published OpenAPI spec, so verification ' +
559
+ 'cannot see them. Before continuing, run `/dezycro:publish-spec` to upload the updated spec (this ' +
560
+ 'regenerates the JEP), then `/dezycro:verify` to check the feature against it. This reminder repeats ' +
561
+ 'each turn until the spec is published and clears automatically after `/dezycro:publish-spec`.';
562
+ return { text, count: paths.length };
563
+ }
516
564
  function maybeSurfaceCoverage(repoRoot, current) {
517
565
  const cfg = safeLoadConfig(repoRoot);
518
566
  if (cfg?.companion?.coverageNudges?.enabled === false)
@@ -96,6 +96,7 @@ exports.DEFAULT_COMPANION_STATE = Object.freeze({
96
96
  commandBus: {
97
97
  cursor: null,
98
98
  lastPollTs: null,
99
+ lastAttemptTs: null,
99
100
  consecutiveFailures: 0,
100
101
  recentEventIds: [],
101
102
  },
@@ -206,10 +206,25 @@ export interface CommandBusConfig {
206
206
  pollHooks?: CommandBusHook[];
207
207
  minPollIntervalSeconds?: number;
208
208
  reactionMode?: CommandBusReactionMode;
209
+ /**
210
+ * After this many consecutive poll failures the effective poll interval grows
211
+ * exponentially (backoff) so an unreachable inbox endpoint stops being polled
212
+ * on every hook fire. Defaults to 3.
213
+ */
214
+ circuitBreakerThreshold?: number;
215
+ /** Upper bound (seconds) on the backed-off poll interval. Defaults to 1800 (30m). */
216
+ maxBackoffSeconds?: number;
209
217
  }
210
218
  export interface CommandBusState {
211
219
  cursor: string | null;
220
+ /** Timestamp of the last SUCCESSFUL poll (server clock). Used as the dedup/cursor anchor. */
212
221
  lastPollTs: string | null;
222
+ /**
223
+ * Timestamp of the last poll ATTEMPT (success or failure, client clock). The
224
+ * min-interval / backoff floor is measured from here so repeated failures are
225
+ * throttled even before a first success exists.
226
+ */
227
+ lastAttemptTs?: string | null;
213
228
  consecutiveFailures: number;
214
229
  recentEventIds: string[];
215
230
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dezycro-ai/agent-plugin",
3
- "version": "2.1.0",
3
+ "version": "2.1.2",
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",
package/setup.js CHANGED
@@ -1,11 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  const { execSync } = require("child_process");
4
+ const fs = require("fs");
5
+ const os = require("os");
6
+ const path = require("path");
4
7
  const readline = require("readline");
5
8
 
6
9
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
7
10
 
8
- console.log(`\n Dezycro MCP Setup`);
11
+ console.log(`\n Dezycro Setup`);
9
12
  console.log(` Get your API key from Dezycro → Settings → API Keys\n`);
10
13
 
11
14
  rl.question(" API key (dzy_...): ", (apiKey) => {
@@ -17,19 +20,34 @@ rl.question(" API key (dzy_...): ", (apiKey) => {
17
20
  process.exit(1);
18
21
  }
19
22
 
20
- const apiUrl = "https://mcp.dezycro.ai";
23
+ const mcpUrl = "https://mcp.dezycro.ai";
21
24
 
25
+ // 1. Configure Claude Code MCP server.
22
26
  try {
23
27
  execSync(
24
- `claude mcp add --transport http dezycro "${apiUrl}/mcp" --header "Authorization: Bearer ${apiKey}"`,
28
+ `claude mcp add --transport http dezycro "${mcpUrl}/mcp" --header "Authorization: Bearer ${apiKey}"`,
25
29
  { stdio: "inherit" }
26
30
  );
27
31
  console.log(`\n ✓ Dezycro MCP configured`);
28
- console.log(`\n You're all set! In Claude Code run: /dezycro:import-features\n`);
29
32
  } catch (err) {
30
33
  console.error(`\n Failed to configure MCP. Run manually:`);
31
- console.error(` claude mcp add --transport http dezycro "${apiUrl}/mcp" --header "Authorization: Bearer ${apiKey}"\n`);
34
+ console.error(` claude mcp add --transport http dezycro "${mcpUrl}/mcp" --header "Authorization: Bearer ${apiKey}"\n`);
32
35
  }
33
36
 
37
+ // 2. Save the user-global Companion token so companion-runner can poll the
38
+ // Agent Command Bus without per-repo config. Project-local
39
+ // .dezycro/companion-token.local.json still overrides this.
40
+ try {
41
+ const globalDir = path.join(os.homedir(), ".dezycro");
42
+ const globalTokenPath = path.join(globalDir, "token.json");
43
+ fs.mkdirSync(globalDir, { recursive: true, mode: 0o700 });
44
+ fs.writeFileSync(globalTokenPath, JSON.stringify({ token: apiKey }, null, 2), { mode: 0o600 });
45
+ console.log(` ✓ Companion token saved to ${globalTokenPath}`);
46
+ } catch (err) {
47
+ console.error(` Failed to save companion token: ${err.message}`);
48
+ console.error(` Drop it manually at ~/.dezycro/token.json: {"token": "${apiKey}"}\n`);
49
+ }
50
+
51
+ console.log(`\n You're all set! In Claude Code run: /dezycro:import-features\n`);
34
52
  rl.close();
35
53
  });