agendex-cli 1.1.0 → 1.3.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.
Files changed (3) hide show
  1. package/README.md +47 -0
  2. package/dist/cli.js +518 -114
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -23,6 +23,10 @@ agendex configure # Select which agents/adapters to index
23
23
  agendex start # Start daemon (backgrounds itself)
24
24
  agendex stop # Stop the running daemon
25
25
  agendex sync # One-shot scan + sync to cloud
26
+ agendex sync --force # Re-sync all plans, ignoring the local hash cache
27
+ agendex upload <path> # Upload a single Markdown plan file to the cloud
28
+ agendex upload <path> --agent <name> # Override the uploaded plan's agent label
29
+ agendex upload <path> --open # Open the uploaded plan in the browser after upload
26
30
  agendex cleanup # Interactively remove cloud daemons
27
31
  agendex cleanup --stale # Auto-remove all stale daemons
28
32
  agendex status # Show config state, daemon status, uptime & hostname
@@ -57,6 +61,27 @@ AGENDEX_DEV=1 agendex sync
57
61
 
58
62
  In dev mode the default OAuth site (when you do not pass `--url` and do not set `AGENDEX_SITE_URL`) points at the local EE app URL used for development.
59
63
 
64
+ ## Plan Value Filtering
65
+
66
+ Agendex uses a shared plan-value classifier (`@agendex/shared`) to keep non-plans out of your library and cloud account. The same rules apply to local OSS indexing, `agendex sync`, and the background daemon.
67
+
68
+ **Locally indexed but hidden** (tagged `lowValue` in plan metadata, excluded from search and list views):
69
+
70
+ - Empty or whitespace-only content
71
+ - Heading-only markdown with no body
72
+ - Prompt-like one-liners, system context dumps, tool logs, conversation artifacts
73
+ - Execution reports, review output, wrapper titles
74
+ - Code-only or code-dominated markdown without plan structure
75
+ - Content with no recognizable planning signals (unstructured one-liners, generic session dumps)
76
+
77
+ **Cloud sync behavior:**
78
+
79
+ - Indexable plans upload normally.
80
+ - Low-value plans are still sent on sync so the cloud can **prune** them: existing cloud copies are deleted and new low-value uploads are skipped.
81
+ - Sync output includes counts such as `N low-value skipped/pruned (M deleted)` when pruning runs.
82
+
83
+ Low-value tagging happens during scan/rescan. If you edit a file into a real plan, the next scan clears the tag and sync uploads it again. Version restore in the cloud rejects low-value snapshots; browse history on a hidden plan to find and restore a good snapshot.
84
+
60
85
  ## Sync Provenance
61
86
 
62
87
  `agendex sync` and the daemon include sync provenance in cloud payload metadata so the web app can show where a plan was synced from. This includes the device ID, hostname, and the host machine's local IP address when one is available.
@@ -67,6 +92,28 @@ You can disable local IP address collection from Account settings in the cloud a
67
92
  AGENDEX_DISABLE_LOCAL_IP=1 agendex sync
68
93
  ```
69
94
 
95
+ ## Real-Time Cloud Sync (Daemon)
96
+
97
+ While `agendex start` is running, the daemon watches local plan sources and uploads changes to your cloud account. The cloud web app updates reactively once uploads land (no manual refresh).
98
+
99
+ **How uploads are scheduled:**
100
+
101
+ 1. File watchers (plus periodic rescans) trigger a local rescan when plans change.
102
+ 2. Each changed plan is converted to a sync payload and enqueued. The queue **deduplicates by plan id** (last write wins).
103
+ 3. **`sync-cache.json`** stores content hashes so unchanged plans are skipped (same as one-shot sync). Use `agendex sync --force` to bypass the cache for a manual full upload.
104
+ 4. Before each upload, the daemon re-checks that the queued payload is still the latest edit for that plan (so a slow retry cannot overwrite a newer change).
105
+ 5. Failed uploads retry automatically with exponential backoff (**2s → 8s → 30s**, up to three attempts) before the daemon logs a permanent failure.
106
+
107
+ Low-value plans follow the same queue and pruning rules as [Plan Value Filtering](#plan-value-filtering).
108
+
109
+ | Variable | Default | Purpose |
110
+ | ------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------- |
111
+ | `AGENDEX_LIVE_SESSION_POLL_MS` | `2000` | Poll active Plannotator live sessions (re-fetch loopback plan content). Set to `0` to disable. |
112
+ | `AGENDEX_SYNC_RESCAN_INTERVAL_MS` | `60000` | Safety-net full rescan + hash-diff upload when `fs.watch` misses an event. Set to `0` to disable. |
113
+ | `AGENDEX_WATCHER_REFRESH_INTERVAL_MS` | `300000` | Re-discover watch directories (new Cursor projects, `@plans` folders, custom dirs). Set to `0` to disable. |
114
+
115
+ Typical latency after a local edit: **~0.5–2s** for file-based agents (Cursor, Claude Code, markdown snapshots); **~2–3s** for Plannotator live-session-only edits (loopback poll).
116
+
70
117
  ## Daemon Cleanup
71
118
 
72
119
  `agendex cleanup` manages registered daemon devices in the cloud.
package/dist/cli.js CHANGED
@@ -1059,8 +1059,8 @@ var init_cleanup = __esm(() => {
1059
1059
 
1060
1060
  // src/cli.ts
1061
1061
  import { spawn as spawn4 } from "node:child_process";
1062
- import { existsSync as existsSync12, statSync as statSync2, writeSync } from "node:fs";
1063
- import { resolve as resolve9 } from "node:path";
1062
+ import { existsSync as existsSync13, statSync as statSync2, writeSync } from "node:fs";
1063
+ import { resolve as resolve10 } from "node:path";
1064
1064
  import { fileURLToPath as fileURLToPath3 } from "node:url";
1065
1065
 
1066
1066
  // ../shared/src/adapters/catalog.ts
@@ -2822,6 +2822,7 @@ function normalizeStoredConfig(raw) {
2822
2822
  const token = typeof raw.token === "string" && raw.token.trim() ? raw.token : undefined;
2823
2823
  const cloudToken = typeof raw.cloudToken === "string" && raw.cloudToken.trim() ? raw.cloudToken : undefined;
2824
2824
  const convexUrl = typeof raw.convexUrl === "string" && raw.convexUrl.trim() ? raw.convexUrl : undefined;
2825
+ const siteUrl = typeof raw.siteUrl === "string" && raw.siteUrl.trim() ? raw.siteUrl : undefined;
2825
2826
  const deviceId = typeof raw.deviceId === "string" && raw.deviceId.trim() ? raw.deviceId : undefined;
2826
2827
  const collectLocalIpAddress = typeof raw.collectLocalIpAddress === "boolean" ? raw.collectLocalIpAddress : undefined;
2827
2828
  return {
@@ -2829,6 +2830,7 @@ function normalizeStoredConfig(raw) {
2829
2830
  token,
2830
2831
  cloudToken,
2831
2832
  convexUrl,
2833
+ siteUrl,
2832
2834
  deviceId,
2833
2835
  collectLocalIpAddress,
2834
2836
  enabledAdapters: normalizeAdapterIds(raw.enabledAdapters),
@@ -2845,6 +2847,7 @@ function saveConfig(config) {
2845
2847
  token: config.token,
2846
2848
  cloudToken: config.cloudToken,
2847
2849
  convexUrl: config.convexUrl,
2850
+ siteUrl: config.siteUrl,
2848
2851
  deviceId: config.deviceId,
2849
2852
  collectLocalIpAddress: config.collectLocalIpAddress,
2850
2853
  enabledAdapters: sanitizeEnabledAdapterIds(config.enabledAdapters),
@@ -2923,6 +2926,7 @@ async function loadOrInitConfig(options = {}) {
2923
2926
  token: tokenFromEnv ? existing?.token : currentToken,
2924
2927
  cloudToken: existing?.cloudToken,
2925
2928
  convexUrl: existing?.convexUrl,
2929
+ siteUrl: existing?.siteUrl,
2926
2930
  deviceId,
2927
2931
  enabledAdapters,
2928
2932
  customPlanDirs: existing?.customPlanDirs ?? []
@@ -2954,6 +2958,7 @@ function isIndexablePlan(plan) {
2954
2958
  }
2955
2959
  var PROPOSED_PLAN_TAG_REGEX2 = /<\s*\/?\s*proposed_plan\s*>/gi;
2956
2960
  var ESCAPED_PROPOSED_PLAN_TAG_REGEX2 = /&lt;\s*\/?\s*proposed_plan\s*&gt;/gi;
2961
+ var FENCED_CODE_BLOCK_REGEX = /(?:```|~~~)[\s\S]*?(?:```|~~~)/g;
2957
2962
  var VISIBLE_TEXT_REGEX = /[\p{L}\p{N}]/u;
2958
2963
  var LOW_VALUE_METADATA_KEYS = ["lowValue", "lowValueReasons", "lowValueSignals"];
2959
2964
  function normalizeLineEndings2(text) {
@@ -2973,7 +2978,7 @@ function normalizePlanContent(content) {
2973
2978
  return stripBoundaryHtmlComments(normalizeLineEndings2(content).replace(/^\uFEFF/, "").replace(/^---\s*\n[\s\S]*?\n---\s*\n?/, "").replace(ESCAPED_PROPOSED_PLAN_TAG_REGEX2, "").replace(PROPOSED_PLAN_TAG_REGEX2, "")).trim();
2974
2979
  }
2975
2980
  function withoutLowValueMetadata(metadata) {
2976
- const next = { ...metadata ?? {} };
2981
+ const next = { ...metadata };
2977
2982
  for (const key of LOW_VALUE_METADATA_KEYS)
2978
2983
  delete next[key];
2979
2984
  return next;
@@ -2984,6 +2989,23 @@ function unique(items) {
2984
2989
  function visibleText(text) {
2985
2990
  return text.replace(/<!--[\s\S]*?-->/g, "").replace(/^---\s*\n[\s\S]*?\n---\s*\n?/, "").replace(ESCAPED_PROPOSED_PLAN_TAG_REGEX2, "").replace(PROPOSED_PLAN_TAG_REGEX2, "").replace(/[`*_#[\](){}<>:|~\-+=.]/g, " ").trim();
2986
2991
  }
2992
+ function stripFencedCodeBlocks(text) {
2993
+ return text.replace(FENCED_CODE_BLOCK_REGEX, " ");
2994
+ }
2995
+ function wordCount(text) {
2996
+ return text.match(/[\p{L}\p{N}_]+/gu)?.length ?? 0;
2997
+ }
2998
+ function codeBlockMetrics(text) {
2999
+ const blocks = text.match(FENCED_CODE_BLOCK_REGEX) ?? [];
3000
+ const codeCharCount = blocks.reduce((sum, block) => sum + block.length, 0);
3001
+ const nonCode = stripFencedCodeBlocks(text);
3002
+ return {
3003
+ codeBlockCount: blocks.length,
3004
+ codeCharCount,
3005
+ codeShare: codeCharCount / Math.max(text.length, 1),
3006
+ nonCodeWordCount: wordCount(visibleText(nonCode))
3007
+ };
3008
+ }
2987
3009
  function isSeparatorLine(line) {
2988
3010
  return /^(?:-{3,}|_{3,}|\*{3,})$/.test(line.trim());
2989
3011
  }
@@ -3007,7 +3029,7 @@ function isOrderedListLine(line) {
3007
3029
  return /^\d+[.)]\s+\S/.test(line.trim());
3008
3030
  }
3009
3031
  function isHeadingOnly(lines) {
3010
- return lines.length > 0 && lines.some(isHeadingLine) && lines.every(isHeadingLine);
3032
+ return lines.some(isHeadingLine) && lines.every(isHeadingLine);
3011
3033
  }
3012
3034
  function metadataHasPlanBlocks(metadata) {
3013
3035
  const planBlocks = metadata?.planBlocks;
@@ -3108,13 +3130,20 @@ function collectPositiveSignals(normalized, lines, metadata) {
3108
3130
  }).length;
3109
3131
  if (actionBulletCount >= 2)
3110
3132
  signals.push("action-bullets");
3111
- if (lines.length >= 2 && /\b(?:will|should|need to|needs to|plan to|planned|approach is to|implementation will)\b/i.test(normalized)) {
3133
+ const nonCodeVisible = visibleText(stripFencedCodeBlocks(normalized));
3134
+ if (lines.length >= 2 && /\b(?:will|should|need to|needs to|plan to|planned|approach is to|implementation will)\b/i.test(nonCodeVisible)) {
3112
3135
  signals.push("future-plan-language");
3113
3136
  }
3137
+ const planningPhraseCount = nonCodeVisible.match(/\b(?:will|should|need to|needs to|plan to|approach|implementation|implement|add|update|modify|create|remove|refactor|test|verify|validate|steps?|tasks?)\b/gi)?.length ?? 0;
3138
+ const proseWordCount = wordCount(nonCodeVisible);
3139
+ const isPlanningProse = proseWordCount >= 35 && planningPhraseCount >= 4 || proseWordCount >= 3 && planningPhraseCount >= 3 && planningPhraseCount / proseWordCount >= 0.4;
3140
+ if (isPlanningProse) {
3141
+ signals.push("planning-prose");
3142
+ }
3114
3143
  return unique(signals);
3115
3144
  }
3116
3145
  function isStrongPositiveSignal(signal) {
3117
- return signal === "metadata:proposed-plan-block" || signal === "checklist" || signal === "ordered-steps" || signal === "action-bullets" || signal === "section:approach" || signal === "section:implementation-plan" || signal === "section:files-to-modify" || signal === "section:steps" || signal === "section:acceptance-criteria";
3146
+ return signal === "metadata:proposed-plan-block" || signal === "checklist" || signal === "ordered-steps" || signal === "action-bullets" || signal === "planning-prose" || signal === "section:approach" || signal === "section:implementation-plan" || signal === "section:files-to-modify" || signal === "section:steps" || signal === "section:acceptance-criteria";
3118
3147
  }
3119
3148
  function hasStrongPlanSignal(positiveSignals) {
3120
3149
  const sectionCount = positiveSignals.filter((signal) => signal.startsWith("section:")).length;
@@ -3177,6 +3206,13 @@ function looksLikeSystemContext(normalized, lines) {
3177
3206
  function looksLikeToolLog(normalized) {
3178
3207
  return /::[a-z0-9_-]+(?:\{|\[|\s*$)/i.test(normalized) || /<\/?(?:tool_call|tool_result)\b[^>]*>/i.test(normalized) || /\b(?:function_call|tool_calls|tool_result)\b/i.test(normalized);
3179
3208
  }
3209
+ function looksLikeConversationArtifact(lines) {
3210
+ const roleLineCount = lines.filter((line) => /^(?:[-*+]\s+)?(?:\*\*)?(?:user|assistant|system|developer|tool|agent)(?:\*\*)?\s*:/i.test(line.trim())).length;
3211
+ if (roleLineCount >= 2)
3212
+ return true;
3213
+ const wrapperCount = lines.filter((line) => /^<\/?(?:user_action|user_prompt|assistant_response|message|conversation)\b/i.test(line.trim())).length;
3214
+ return wrapperCount >= 2;
3215
+ }
3180
3216
  function looksLikeExecutionReport(normalized) {
3181
3217
  const hasPastCompletion = /\b(?:fixed|pushed|committed|completed|done|implemented|updated|changed|patched|merged|deployed|passed|failed|resolved|reverted)\b/i.test(normalized);
3182
3218
  const hasReportSection = /^\s*(?:summary|result|results|changes|verification|status)\s*:/im.test(normalized);
@@ -3208,14 +3244,20 @@ function assessPlanValue(input) {
3208
3244
  const strongPositive = hasStrongPlanSignal(positiveSignals);
3209
3245
  const systemContext = looksLikeSystemContext(normalized, lines);
3210
3246
  const toolLog = looksLikeToolLog(normalized);
3247
+ const conversationArtifact = looksLikeConversationArtifact(lines);
3211
3248
  const executionReport = looksLikeExecutionReport(normalized);
3212
3249
  const wrapperTitle = looksLikeWrapperTitle(input.title);
3213
3250
  const promptTitle = looksLikePromptTitle(input.title);
3214
3251
  const reviewOutput = looksLikeReviewOutput(normalized, input.title);
3252
+ const codeMetrics = codeBlockMetrics(normalized);
3253
+ const codeOnly = codeMetrics.codeBlockCount > 0 && codeMetrics.nonCodeWordCount === 0;
3254
+ const codeDominated = codeMetrics.codeBlockCount > 0 && codeMetrics.codeShare >= 0.6 && codeMetrics.nonCodeWordCount < 120;
3215
3255
  if (systemContext)
3216
3256
  signals.push("negative:system-context");
3217
3257
  if (toolLog)
3218
3258
  signals.push("negative:tool-log");
3259
+ if (conversationArtifact)
3260
+ signals.push("negative:conversation-artifact");
3219
3261
  if (executionReport)
3220
3262
  signals.push("negative:execution-report");
3221
3263
  if (wrapperTitle)
@@ -3224,24 +3266,36 @@ function assessPlanValue(input) {
3224
3266
  signals.push("negative:prompt-title");
3225
3267
  if (reviewOutput)
3226
3268
  signals.push("negative:review-output");
3269
+ if (codeMetrics.codeBlockCount > 0)
3270
+ signals.push("shape:code-blocks");
3271
+ if (codeOnly)
3272
+ signals.push("negative:code-only");
3273
+ if (codeDominated)
3274
+ signals.push("negative:code-dominated");
3227
3275
  if (lines.length === 1)
3228
3276
  signals.push("shape:single-line");
3229
3277
  if (lines.length === 1 && positiveSignals.length === 0) {
3230
3278
  reasons.push(isPromptLikeOneLiner(lines[0] ?? "") ? "prompt-like" : "no-plan-signals");
3231
3279
  }
3232
- if (systemContext && !strongPositive)
3280
+ if (systemContext && !explicitPlanBlock)
3233
3281
  reasons.push("system-context");
3234
- if (executionReport && !strongPositive)
3282
+ if (toolLog && !explicitPlanBlock)
3283
+ reasons.push("tool-log");
3284
+ if (conversationArtifact && !explicitPlanBlock && !strongPositive)
3285
+ reasons.push("conversation-artifact");
3286
+ if (executionReport && !explicitPlanBlock)
3235
3287
  reasons.push("execution-report");
3236
3288
  if (wrapperTitle && !explicitPlanBlock)
3237
3289
  reasons.push("wrapper-title");
3238
3290
  if (reviewOutput && !explicitPlanBlock)
3239
3291
  reasons.push("review-output");
3240
- if (promptTitle && !strongPositive && positiveSignals.length === 0)
3292
+ if (promptTitle && !strongPositive && !explicitPlanBlock)
3241
3293
  reasons.push("prompt-like");
3242
- if (toolLog && !strongPositive && positiveSignals.length === 0)
3243
- reasons.push("no-plan-signals");
3244
- if (reasons.length === 0 && positiveSignals.length === 0 && lines.length <= 3) {
3294
+ if (codeOnly && !explicitPlanBlock && !strongPositive)
3295
+ reasons.push("code-only");
3296
+ if (codeDominated && !explicitPlanBlock && !strongPositive)
3297
+ reasons.push("code-dominated");
3298
+ if (reasons.length === 0 && !strongPositive && !positiveSignals.includes("planning-prose")) {
3245
3299
  reasons.push("no-plan-signals");
3246
3300
  }
3247
3301
  return lowValueAssessment(reasons, signals);
@@ -3422,6 +3476,9 @@ async function walkDir(dir, depth = 0, seen = new Set) {
3422
3476
  } catch {}
3423
3477
  return files;
3424
3478
  }
3479
+ function preparePlanForIndex(plan) {
3480
+ return annotatePlanValueMetadata(plan);
3481
+ }
3425
3482
  async function parseGenericMarkdownPlan(filePath, extraMetadata) {
3426
3483
  try {
3427
3484
  const content = await readFile7(filePath, "utf-8");
@@ -3461,7 +3518,7 @@ async function scanUserPlans(into) {
3461
3518
  continue;
3462
3519
  const plan = await parseGenericMarkdownPlan(file, { userCreated: true });
3463
3520
  if (plan)
3464
- into.set(plan.id, plan);
3521
+ into.set(plan.id, preparePlanForIndex(plan));
3465
3522
  }
3466
3523
  }
3467
3524
  function getCustomPlanDirs() {
@@ -3523,7 +3580,7 @@ async function scanCustomPlanDirs(coveredPaths, into) {
3523
3580
  agentHint: dirBasename
3524
3581
  });
3525
3582
  if (plan) {
3526
- into.set(plan.id, plan);
3583
+ into.set(plan.id, preparePlanForIndex(plan));
3527
3584
  count++;
3528
3585
  }
3529
3586
  }
@@ -3543,7 +3600,7 @@ async function scan() {
3543
3600
  continue;
3544
3601
  const plans = await adapter.parse(file);
3545
3602
  for (const plan of plans) {
3546
- const annotated = annotatePlanValueMetadata(plan);
3603
+ const annotated = preparePlanForIndex(plan);
3547
3604
  next.set(annotated.id, annotated);
3548
3605
  }
3549
3606
  }
@@ -3563,7 +3620,7 @@ async function scan() {
3563
3620
  continue;
3564
3621
  const plans = await adapter.parse(file);
3565
3622
  for (const plan of plans) {
3566
- const annotated = annotatePlanValueMetadata(plan);
3623
+ const annotated = preparePlanForIndex(plan);
3567
3624
  next.set(annotated.id, annotated);
3568
3625
  }
3569
3626
  }
@@ -3660,7 +3717,7 @@ async function rescanFile(filePath) {
3660
3717
  removedPlans.push(...removePlansForPath(filePath, adapter));
3661
3718
  continue;
3662
3719
  }
3663
- const plans = rawPlans.map(annotatePlanValueMetadata);
3720
+ const plans = rawPlans.map(preparePlanForIndex);
3664
3721
  for (const plan of plans) {
3665
3722
  store.set(plan.id, plan);
3666
3723
  }
@@ -3673,9 +3730,10 @@ async function rescanFile(filePath) {
3673
3730
  if (normalized.endsWith(".md") && (normalized.startsWith(userPlansDir + sep3) || normalized === userPlansDir)) {
3674
3731
  const plan = await parseGenericMarkdownPlan(filePath, { userCreated: true });
3675
3732
  if (plan) {
3676
- store.set(plan.id, plan);
3733
+ const annotated = preparePlanForIndex(plan);
3734
+ store.set(annotated.id, annotated);
3677
3735
  notifyPlansChanged();
3678
- return [plan];
3736
+ return [annotated];
3679
3737
  }
3680
3738
  }
3681
3739
  if (normalized.endsWith(".md")) {
@@ -3688,9 +3746,10 @@ async function rescanFile(filePath) {
3688
3746
  customDir: dir
3689
3747
  });
3690
3748
  if (plan) {
3691
- store.set(plan.id, plan);
3749
+ const annotated = preparePlanForIndex(plan);
3750
+ store.set(annotated.id, annotated);
3692
3751
  notifyPlansChanged();
3693
- return [plan];
3752
+ return [annotated];
3694
3753
  }
3695
3754
  }
3696
3755
  }
@@ -3751,6 +3810,38 @@ function startWatching(onChange) {
3751
3810
  function stopWatching() {
3752
3811
  closeAllWatchers();
3753
3812
  }
3813
+ function collectWatchPaths() {
3814
+ const adapters = getActiveAdapters();
3815
+ const watchedPaths = new Set;
3816
+ for (const adapter of adapters) {
3817
+ for (const watchPath of adapter.getWatchPaths()) {
3818
+ watchedPaths.add(resolve5(watchPath));
3819
+ }
3820
+ }
3821
+ const discovered = discoverProjectPlanDirs();
3822
+ for (const { dir, agent } of discovered) {
3823
+ if (watchedPaths.has(resolve5(dir)))
3824
+ continue;
3825
+ const adapter = adapters.find((a) => a.agent === agent);
3826
+ if (!adapter)
3827
+ continue;
3828
+ watchedPaths.add(resolve5(dir));
3829
+ }
3830
+ for (const dir of getCustomPlanDirs()) {
3831
+ const resolvedCustom = resolve5(dir);
3832
+ let overlaps = false;
3833
+ for (const watched of watchedPaths) {
3834
+ if (pathsOverlapFilesystemTree(resolvedCustom, watched)) {
3835
+ overlaps = true;
3836
+ break;
3837
+ }
3838
+ }
3839
+ if (overlaps)
3840
+ continue;
3841
+ watchedPaths.add(resolvedCustom);
3842
+ }
3843
+ return [...watchedPaths].sort();
3844
+ }
3754
3845
  function setupWatchers(onChange) {
3755
3846
  const adapters = getActiveAdapters();
3756
3847
  const watchedPaths = new Set;
@@ -3870,7 +3961,8 @@ function parseSyncSuccess(body) {
3870
3961
  return {
3871
3962
  ok: true,
3872
3963
  skippedLowValue: result.skippedLowValue === true,
3873
- deleted: result.deleted === true
3964
+ deleted: result.deleted === true,
3965
+ ...typeof result.planId === "string" && { planId: result.planId }
3874
3966
  };
3875
3967
  } catch {
3876
3968
  return { ok: true };
@@ -3905,7 +3997,7 @@ async function syncPlan(plan) {
3905
3997
  }
3906
3998
  }
3907
3999
  if (res.status < 200 || res.status >= 300) {
3908
- return { ok: false, error: `${res.status}: ${res.body}` };
4000
+ return { ok: false, status: res.status, error: `${res.status}: ${res.body}` };
3909
4001
  }
3910
4002
  return parseSyncSuccess(res.body);
3911
4003
  }
@@ -4191,6 +4283,12 @@ function getDefaultSiteUrl() {
4191
4283
  return process.env.AGENDEX_SITE_URL;
4192
4284
  return isDevMode() ? DEV_SITE_URL : PROD_SITE_URL;
4193
4285
  }
4286
+ function getSiteUrl() {
4287
+ const config = loadConfig();
4288
+ if (config?.siteUrl)
4289
+ return config.siteUrl;
4290
+ return getDefaultSiteUrl();
4291
+ }
4194
4292
  function launchBrowser(url, label) {
4195
4293
  console.log(`[agendex] Opening ${label}...`);
4196
4294
  console.log(`[agendex] If it doesn't open, visit: ${url}`);
@@ -4203,15 +4301,16 @@ function launchBrowser(url, label) {
4203
4301
  async function login(siteUrlOverride) {
4204
4302
  const { port, result } = await startCallbackServer();
4205
4303
  const callbackUrl = `http://127.0.0.1:${port}/callback`;
4206
- const siteUrl = siteUrlOverride ?? getDefaultSiteUrl();
4304
+ const existing = loadConfig();
4305
+ const siteUrl = siteUrlOverride ?? existing?.siteUrl ?? getDefaultSiteUrl();
4207
4306
  const authUrl = `${siteUrl}/auth/cli?callback=${encodeURIComponent(callbackUrl)}`;
4208
4307
  launchBrowser(authUrl, "browser for authentication");
4209
4308
  const callback = await result;
4210
- const existing = loadConfig();
4211
4309
  const config = {
4212
4310
  configVersion: 3,
4213
4311
  cloudToken: callback.token,
4214
4312
  convexUrl: callback.convexUrl,
4313
+ siteUrl,
4215
4314
  enabledAdapters: existing?.enabledAdapters ?? [],
4216
4315
  customPlanDirs: existing?.customPlanDirs ?? [],
4217
4316
  ...existing?.token ? { token: existing.token } : {},
@@ -4232,7 +4331,8 @@ function logout() {
4232
4331
  enabledAdapters: existing.enabledAdapters,
4233
4332
  customPlanDirs: existing.customPlanDirs,
4234
4333
  ...existing.token ? { token: existing.token } : {},
4235
- ...existing.deviceId ? { deviceId: existing.deviceId } : {}
4334
+ ...existing.deviceId ? { deviceId: existing.deviceId } : {},
4335
+ ...existing.siteUrl ? { siteUrl: existing.siteUrl } : {}
4236
4336
  };
4237
4337
  saveConfig(config);
4238
4338
  console.log("[agendex] Logged out. Cloud token removed.");
@@ -4376,7 +4476,7 @@ function spawnBrowser(command, args, options = {}) {
4376
4476
  // src/daemon.ts
4377
4477
  import { spawn as spawn2 } from "node:child_process";
4378
4478
  import { hostname as osHostname2 } from "node:os";
4379
- import { resolve as resolve6 } from "node:path";
4479
+ import { resolve as resolve7 } from "node:path";
4380
4480
  import { fileURLToPath } from "node:url";
4381
4481
 
4382
4482
  // src/adapters.ts
@@ -4405,6 +4505,86 @@ function resolveCliAdapterIds(config) {
4405
4505
  return ids;
4406
4506
  }
4407
4507
 
4508
+ // src/sync-cache.ts
4509
+ import { createHash as createHash2 } from "node:crypto";
4510
+ import { existsSync as existsSync8, mkdirSync as mkdirSync3, readFileSync as readFileSync5, writeFileSync as writeFileSync3 } from "node:fs";
4511
+ import { join as join12 } from "node:path";
4512
+ function getCachePath() {
4513
+ return join12(getConfigDir(), "sync-cache.json");
4514
+ }
4515
+ function loadSyncCache() {
4516
+ const cachePath = getCachePath();
4517
+ if (!existsSync8(cachePath))
4518
+ return {};
4519
+ try {
4520
+ const raw = JSON.parse(readFileSync5(cachePath, "utf-8"));
4521
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
4522
+ return {};
4523
+ return raw;
4524
+ } catch {
4525
+ return {};
4526
+ }
4527
+ }
4528
+ function saveSyncCache(cache, options) {
4529
+ const dir = getConfigDir();
4530
+ if (!existsSync8(dir))
4531
+ mkdirSync3(dir, { recursive: true });
4532
+ const cachePath = getCachePath();
4533
+ if (options?.replace) {
4534
+ writeFileSync3(cachePath, JSON.stringify(cache));
4535
+ return;
4536
+ }
4537
+ const existing = loadSyncCache();
4538
+ writeFileSync3(cachePath, JSON.stringify({ ...existing, ...cache }));
4539
+ }
4540
+ function computePayloadHash(payload) {
4541
+ const canonical = JSON.stringify([
4542
+ payload.localPlanId,
4543
+ payload.agent,
4544
+ payload.title,
4545
+ payload.content,
4546
+ payload.format,
4547
+ payload.filePath ?? null,
4548
+ payload.workspace ?? null,
4549
+ payload.metadata ?? null,
4550
+ payload.createdAt ?? null,
4551
+ payload.updatedAt ?? null
4552
+ ]);
4553
+ return createHash2("sha256").update(canonical).digest("hex").slice(0, 20);
4554
+ }
4555
+
4556
+ // src/daemon-sync.ts
4557
+ var DEFAULT_LIVE_SESSION_POLL_MS = 2000;
4558
+ var DEFAULT_SYNC_RESCAN_INTERVAL_MS = 60000;
4559
+ var DEFAULT_WATCHER_REFRESH_INTERVAL_MS = 300000;
4560
+ var SYNC_RETRY_DELAYS_MS = [2000, 8000, 30000];
4561
+ var SYNC_MAX_RETRIES = SYNC_RETRY_DELAYS_MS.length;
4562
+ function parseEnvMs(name, defaultMs) {
4563
+ const raw = process.env[name];
4564
+ if (raw === undefined || raw === "")
4565
+ return defaultMs;
4566
+ const parsed = Number.parseInt(raw, 10);
4567
+ if (!Number.isFinite(parsed) || parsed < 0)
4568
+ return defaultMs;
4569
+ return parsed;
4570
+ }
4571
+ function dedupeSyncPayloads(payloads) {
4572
+ const byId = new Map;
4573
+ for (const payload of payloads) {
4574
+ byId.set(payload.localPlanId, payload);
4575
+ }
4576
+ return [...byId.values()];
4577
+ }
4578
+ function payloadNeedsSync(payload, cache) {
4579
+ return cache[payload.localPlanId] !== computePayloadHash(payload);
4580
+ }
4581
+ function filterPayloadsNeedingSync(payloads, cache) {
4582
+ return payloads.filter((payload) => payloadNeedsSync(payload, cache));
4583
+ }
4584
+ function nextRetryDelayMs(attempt) {
4585
+ return SYNC_RETRY_DELAYS_MS[attempt];
4586
+ }
4587
+
4408
4588
  // src/network.ts
4409
4589
  import { execFileSync } from "node:child_process";
4410
4590
  import { networkInterfaces } from "node:os";
@@ -4521,6 +4701,7 @@ function isIpv4Address(address) {
4521
4701
  }
4522
4702
 
4523
4703
  // src/payload.ts
4704
+ import { basename as basename6, resolve as resolve6 } from "node:path";
4524
4705
  var SYNC_METADATA_KEY = "agendexSync";
4525
4706
  function isRecord4(value) {
4526
4707
  return typeof value === "object" && value !== null;
@@ -4539,6 +4720,36 @@ function withSyncDeviceMetadata(metadata, deviceId, hostname2, ipAddress) {
4539
4720
  }
4540
4721
  };
4541
4722
  }
4723
+ function parseUploadFile(filePath, content, agentOverride) {
4724
+ const fmMatch = content.match(/^---\s*\n([\s\S]*?)\n---\s*\n/);
4725
+ const override = agentOverride?.trim();
4726
+ let agent = override || "uploaded";
4727
+ if (!override && fmMatch) {
4728
+ const agentLine = fmMatch[1]?.match(/^agent:\s*(.+)$/m);
4729
+ if (agentLine?.[1])
4730
+ agent = agentLine[1].trim();
4731
+ }
4732
+ const body = fmMatch ? content.slice(fmMatch[0].length) : content;
4733
+ const titleMatch = body.match(/^#\s+(.+)/m);
4734
+ const title = titleMatch?.[1]?.trim() || basename6(filePath).replace(/\.md$/i, "") || "Untitled";
4735
+ return { title, agent, body };
4736
+ }
4737
+ function fileToSyncPayload(filePath, content, options = {}) {
4738
+ const absolutePath = resolve6(filePath);
4739
+ const { title, agent, body } = parseUploadFile(absolutePath, content, options.agentOverride);
4740
+ const now = Date.now();
4741
+ return {
4742
+ localPlanId: hashPath(absolutePath),
4743
+ agent,
4744
+ title,
4745
+ content: body,
4746
+ format: "md",
4747
+ filePath: absolutePath,
4748
+ metadata: withSyncDeviceMetadata({ uploaded: true, userCreated: true }, options.deviceId, options.hostname, options.ipAddress),
4749
+ createdAt: options.createdAt ?? now,
4750
+ updatedAt: options.updatedAt ?? now
4751
+ };
4752
+ }
4542
4753
  function planToSyncPayload(plan, deviceId, hostname2, ipAddress) {
4543
4754
  return {
4544
4755
  localPlanId: plan.id,
@@ -4554,54 +4765,6 @@ function planToSyncPayload(plan, deviceId, hostname2, ipAddress) {
4554
4765
  };
4555
4766
  }
4556
4767
 
4557
- // src/sync-cache.ts
4558
- import { createHash as createHash2 } from "node:crypto";
4559
- import { existsSync as existsSync8, mkdirSync as mkdirSync3, readFileSync as readFileSync5, writeFileSync as writeFileSync3 } from "node:fs";
4560
- import { join as join12 } from "node:path";
4561
- function getCachePath() {
4562
- return join12(getConfigDir(), "sync-cache.json");
4563
- }
4564
- function loadSyncCache() {
4565
- const cachePath = getCachePath();
4566
- if (!existsSync8(cachePath))
4567
- return {};
4568
- try {
4569
- const raw = JSON.parse(readFileSync5(cachePath, "utf-8"));
4570
- if (!raw || typeof raw !== "object" || Array.isArray(raw))
4571
- return {};
4572
- return raw;
4573
- } catch {
4574
- return {};
4575
- }
4576
- }
4577
- function saveSyncCache(cache, options) {
4578
- const dir = getConfigDir();
4579
- if (!existsSync8(dir))
4580
- mkdirSync3(dir, { recursive: true });
4581
- const cachePath = getCachePath();
4582
- if (options?.replace) {
4583
- writeFileSync3(cachePath, JSON.stringify(cache));
4584
- return;
4585
- }
4586
- const existing = loadSyncCache();
4587
- writeFileSync3(cachePath, JSON.stringify({ ...existing, ...cache }));
4588
- }
4589
- function computePayloadHash(payload) {
4590
- const canonical = JSON.stringify([
4591
- payload.localPlanId,
4592
- payload.agent,
4593
- payload.title,
4594
- payload.content,
4595
- payload.format,
4596
- payload.filePath ?? null,
4597
- payload.workspace ?? null,
4598
- payload.metadata ?? null,
4599
- payload.createdAt ?? null,
4600
- payload.updatedAt ?? null
4601
- ]);
4602
- return createHash2("sha256").update(canonical).digest("hex").slice(0, 20);
4603
- }
4604
-
4605
4768
  // src/sync-privacy.ts
4606
4769
  async function shouldIncludeLocalIpAddressInSync() {
4607
4770
  if (!shouldCollectLocalIpAddress())
@@ -4668,6 +4831,10 @@ var PLANNOTATOR_WRITEBACK_POLL_INTERVAL_MS = 15000;
4668
4831
  var PLANNOTATOR_WRITEBACK_EXPIRED_ERROR = "Write-back expired before delivery.";
4669
4832
  var PLANNOTATOR_WRITEBACK_FAILED_ERROR = "No live Plannotator session accepted the write-back payload.";
4670
4833
  var PLANNOTATOR_LIVENESS_SWEEP_INTERVAL_MS = 20000;
4834
+ var LIVE_SESSION_POLL_MS = parseEnvMs("AGENDEX_LIVE_SESSION_POLL_MS", DEFAULT_LIVE_SESSION_POLL_MS);
4835
+ var SYNC_RESCAN_INTERVAL_MS = parseEnvMs("AGENDEX_SYNC_RESCAN_INTERVAL_MS", DEFAULT_SYNC_RESCAN_INTERVAL_MS);
4836
+ var WATCHER_REFRESH_INTERVAL_MS = parseEnvMs("AGENDEX_WATCHER_REFRESH_INTERVAL_MS", DEFAULT_WATCHER_REFRESH_INTERVAL_MS);
4837
+ var RETRY_TICK_INTERVAL_MS = 1000;
4671
4838
  function isRecord5(value) {
4672
4839
  return typeof value === "object" && value !== null;
4673
4840
  }
@@ -4701,6 +4868,9 @@ async function runWorker() {
4701
4868
  setActiveAdapters(adapters);
4702
4869
  const syncCache = loadSyncCache();
4703
4870
  const syncQueue = [];
4871
+ const retryQueue = [];
4872
+ const retryAttemptByPlanId = new Map;
4873
+ const lastSyncedUpdatedAt = new Map;
4704
4874
  const pendingWritebackReports = loadPendingWritebackReports();
4705
4875
  const liveSessions = new Map;
4706
4876
  let syncing = false;
@@ -4726,17 +4896,76 @@ async function runWorker() {
4726
4896
  console.log("[agendex] cloud token refreshed");
4727
4897
  return true;
4728
4898
  }
4899
+ function pushToSyncQueue(...payloads) {
4900
+ for (const payload of payloads) {
4901
+ const idx = syncQueue.findIndex((p) => p.localPlanId === payload.localPlanId);
4902
+ if (idx >= 0) {
4903
+ const existing = syncQueue[idx];
4904
+ if (existing?.updatedAt !== undefined && payload.updatedAt !== undefined && existing.updatedAt >= payload.updatedAt) {
4905
+ continue;
4906
+ }
4907
+ syncQueue[idx] = payload;
4908
+ } else {
4909
+ syncQueue.push(payload);
4910
+ }
4911
+ }
4912
+ }
4913
+ function scheduleSyncRetry(payload, attempt) {
4914
+ const delayMs = nextRetryDelayMs(attempt);
4915
+ if (delayMs === undefined) {
4916
+ console.error(`[agendex] sync gave up for "${payload.title}" after ${SYNC_MAX_RETRIES} attempts`);
4917
+ return;
4918
+ }
4919
+ retryQueue.push({ payload, attempt: attempt + 1, retryAt: Date.now() + delayMs });
4920
+ }
4921
+ function flushReadyRetries() {
4922
+ const now = Date.now();
4923
+ let moved = 0;
4924
+ for (let i = retryQueue.length - 1;i >= 0; i--) {
4925
+ const entry = retryQueue[i];
4926
+ if (!entry || entry.retryAt > now)
4927
+ continue;
4928
+ retryQueue.splice(i, 1);
4929
+ const lastSynced = lastSyncedUpdatedAt.get(entry.payload.localPlanId);
4930
+ if (lastSynced !== undefined && entry.payload.updatedAt !== undefined && lastSynced >= entry.payload.updatedAt) {
4931
+ continue;
4932
+ }
4933
+ pushToSyncQueue(entry.payload);
4934
+ retryAttemptByPlanId.set(entry.payload.localPlanId, entry.attempt);
4935
+ moved++;
4936
+ }
4937
+ if (moved > 0)
4938
+ processSyncQueue();
4939
+ }
4940
+ async function enqueueChangedPlans(plans2) {
4941
+ if (plans2.length === 0)
4942
+ return 0;
4943
+ const ipAddress = await getSyncIpAddress();
4944
+ const payloads = plans2.map((plan) => planToSyncPayload(plan, config.deviceId, hostname2, ipAddress));
4945
+ const needingSync = filterPayloadsNeedingSync(payloads, syncCache);
4946
+ if (needingSync.length === 0)
4947
+ return 0;
4948
+ pushToSyncQueue(...needingSync);
4949
+ processSyncQueue();
4950
+ return needingSync.length;
4951
+ }
4729
4952
  async function processSyncQueue() {
4730
4953
  if (syncing || syncQueue.length === 0)
4731
4954
  return;
4732
4955
  syncing = true;
4733
- const batch = syncQueue.splice(0);
4956
+ const batch = dedupeSyncPayloads(syncQueue.splice(0));
4734
4957
  let syncedCount = 0;
4735
4958
  let lowValueSkippedCount = 0;
4736
4959
  let lowValueDeletedCount = 0;
4737
4960
  let failedCount = 0;
4961
+ const failedPayloads = [];
4738
4962
  try {
4739
4963
  for (const payload of batch) {
4964
+ const lastSynced = lastSyncedUpdatedAt.get(payload.localPlanId);
4965
+ if (lastSynced !== undefined && payload.updatedAt !== undefined && lastSynced >= payload.updatedAt) {
4966
+ retryAttemptByPlanId.delete(payload.localPlanId);
4967
+ continue;
4968
+ }
4740
4969
  let result = await syncPlan(payload);
4741
4970
  if (!result.ok && result.error?.includes("401")) {
4742
4971
  const refreshed = await tryRefreshToken();
@@ -4747,11 +4976,12 @@ async function runWorker() {
4747
4976
  if (!result.ok) {
4748
4977
  if (result.error?.includes("401")) {
4749
4978
  console.error("[agendex] session expired. Run `agendex login` to re-authenticate.");
4750
- batch.length = 0;
4751
4979
  syncQueue.length = 0;
4980
+ retryQueue.length = 0;
4752
4981
  break;
4753
4982
  }
4754
4983
  failedCount++;
4984
+ failedPayloads.push(payload);
4755
4985
  console.error(`[agendex] sync failed for "${payload.title}": ${result.error}`);
4756
4986
  } else {
4757
4987
  if (result.skippedLowValue) {
@@ -4762,14 +4992,23 @@ async function runWorker() {
4762
4992
  syncedCount++;
4763
4993
  }
4764
4994
  syncCache[payload.localPlanId] = computePayloadHash(payload);
4995
+ retryAttemptByPlanId.delete(payload.localPlanId);
4996
+ if (payload.updatedAt !== undefined) {
4997
+ lastSyncedUpdatedAt.set(payload.localPlanId, payload.updatedAt);
4998
+ }
4765
4999
  }
4766
5000
  }
4767
5001
  } catch (err) {
4768
5002
  console.error("[agendex] sync error:", err);
4769
- syncQueue.unshift(...batch.slice(syncedCount + lowValueSkippedCount + failedCount));
5003
+ pushToSyncQueue(...batch.slice(syncedCount + lowValueSkippedCount + failedCount));
4770
5004
  } finally {
4771
5005
  syncing = false;
4772
5006
  }
5007
+ for (const payload of failedPayloads) {
5008
+ const attempt = retryAttemptByPlanId.get(payload.localPlanId) ?? 0;
5009
+ retryAttemptByPlanId.delete(payload.localPlanId);
5010
+ scheduleSyncRetry(payload, attempt);
5011
+ }
4773
5012
  if (syncedCount > 0 || lowValueSkippedCount > 0 || failedCount > 0) {
4774
5013
  saveSyncCache(syncCache);
4775
5014
  const lowValueSuffix2 = lowValueSkippedCount > 0 ? `, ${lowValueSkippedCount} low-value skipped/pruned${lowValueDeletedCount > 0 ? ` (${lowValueDeletedCount} deleted)` : ""}` : "";
@@ -4791,7 +5030,7 @@ async function runWorker() {
4791
5030
  if (livePayloads.has(planId))
4792
5031
  continue;
4793
5032
  const endedPayload = buildEndedPlannotatorPayload(lastPayload);
4794
- syncQueue.push(endedPayload);
5033
+ pushToSyncQueue(endedPayload);
4795
5034
  liveSessions.delete(planId);
4796
5035
  queued = true;
4797
5036
  console.log(`[agendex] Plannotator session ended: ${endedPayload.title}`);
@@ -4857,7 +5096,7 @@ async function runWorker() {
4857
5096
  const updatedPlan = getById(job.localPlanId);
4858
5097
  if (updatedPlan) {
4859
5098
  const updatedPayload = planToSyncPayload(updatedPlan, config.deviceId, hostname2, await getSyncIpAddress());
4860
- syncQueue.push(updatedPayload);
5099
+ pushToSyncQueue(updatedPayload);
4861
5100
  if (isLivePlannotatorPayload(updatedPayload)) {
4862
5101
  liveSessions.set(updatedPayload.localPlanId, updatedPayload);
4863
5102
  }
@@ -4892,7 +5131,15 @@ async function runWorker() {
4892
5131
  pollingWritebacks = false;
4893
5132
  }
4894
5133
  }
4895
- setOnPlansChanged(() => {});
5134
+ let lastWatchPathKey = collectWatchPaths().join("\x00");
5135
+ const onPlansFileChange = (changedPlans) => {
5136
+ (async () => {
5137
+ await enqueueChangedPlans(changedPlans);
5138
+ await reconcileLivePlannotatorSessions(getAll());
5139
+ })().catch((err) => {
5140
+ console.error("[agendex] failed to queue changed plans:", err);
5141
+ });
5142
+ };
4896
5143
  console.log(`[agendex] initial scan...`);
4897
5144
  await scan();
4898
5145
  const plans = getAll();
@@ -4904,8 +5151,7 @@ async function runWorker() {
4904
5151
  const initialIpAddress = await getSyncIpAddress();
4905
5152
  for (const plan of plans) {
4906
5153
  const payload = planToSyncPayload(plan, config.deviceId, hostname2, initialIpAddress);
4907
- const hash = computePayloadHash(payload);
4908
- if (syncCache[plan.id] === hash) {
5154
+ if (syncCache[plan.id] === computePayloadHash(payload)) {
4909
5155
  initialSkipped++;
4910
5156
  continue;
4911
5157
  }
@@ -4914,7 +5160,7 @@ async function runWorker() {
4914
5160
  } else {
4915
5161
  initialQueuedSyncable++;
4916
5162
  }
4917
- syncQueue.push(payload);
5163
+ pushToSyncQueue(payload);
4918
5164
  }
4919
5165
  const activePlanIds = new Set(plans.map((plan) => plan.id));
4920
5166
  for (const id of Object.keys(syncCache)) {
@@ -4926,35 +5172,75 @@ async function runWorker() {
4926
5172
  console.log(`[agendex] syncing ${initialQueuedSyncable} plans${lowValueSuffix} (${initialQueuedLowValue} low-value queued, ${initialSkipped} unchanged)...`);
4927
5173
  await processSyncQueue();
4928
5174
  await reconcileLivePlannotatorSessions(getAll());
5175
+ setOnPlansChanged((plans2) => {
5176
+ enqueueChangedPlans(plans2).catch((err) => {
5177
+ console.error("[agendex] failed to sync plan store changes:", err);
5178
+ });
5179
+ });
4929
5180
  setInterval(() => {
4930
5181
  (async () => {
4931
5182
  await sendHeartbeat(await getSyncIpAddress());
4932
5183
  })().catch(() => {});
4933
5184
  }, CLI_DAEMON_HEARTBEAT_INTERVAL_MS);
5185
+ setInterval(() => {
5186
+ flushReadyRetries();
5187
+ }, RETRY_TICK_INTERVAL_MS);
5188
+ if (SYNC_RESCAN_INTERVAL_MS > 0) {
5189
+ setInterval(() => {
5190
+ (async () => {
5191
+ await scan();
5192
+ await enqueueChangedPlans(getAll());
5193
+ await reconcileLivePlannotatorSessions(getAll());
5194
+ })().catch((err) => {
5195
+ console.error("[agendex] safety rescan failed:", err);
5196
+ });
5197
+ }, SYNC_RESCAN_INTERVAL_MS);
5198
+ }
5199
+ if (WATCHER_REFRESH_INTERVAL_MS > 0) {
5200
+ setInterval(() => {
5201
+ const nextKey = collectWatchPaths().join("\x00");
5202
+ if (nextKey === lastWatchPathKey)
5203
+ return;
5204
+ lastWatchPathKey = nextKey;
5205
+ console.log("[agendex] watch paths changed, refreshing file watchers...");
5206
+ startWatching(onPlansFileChange);
5207
+ }, WATCHER_REFRESH_INTERVAL_MS);
5208
+ }
4934
5209
  if (shouldEnablePlannotatorSync(config)) {
4935
5210
  setInterval(() => void pollPlannotatorWritebacks(), PLANNOTATOR_WRITEBACK_POLL_INTERVAL_MS);
4936
5211
  pollPlannotatorWritebacks();
4937
5212
  setInterval(() => {
4938
5213
  (async () => {
4939
5214
  await scan();
5215
+ await enqueueChangedPlans(getAll());
4940
5216
  await reconcileLivePlannotatorSessions(getAll());
4941
5217
  })().catch((err) => {
4942
5218
  console.error("[agendex] Plannotator liveness sweep failed:", err);
4943
5219
  });
4944
5220
  }, PLANNOTATOR_LIVENESS_SWEEP_INTERVAL_MS);
5221
+ if (LIVE_SESSION_POLL_MS > 0) {
5222
+ setInterval(() => {
5223
+ (async () => {
5224
+ const ipAddress = await getSyncIpAddress();
5225
+ const livePlans = getAll().filter((plan) => {
5226
+ const payload = planToSyncPayload(plan, config.deviceId, hostname2, ipAddress);
5227
+ return isLivePlannotatorPayload(payload);
5228
+ });
5229
+ if (livePlans.length === 0)
5230
+ return;
5231
+ await scan();
5232
+ const refreshedLive = getAll().filter((plan) => {
5233
+ const payload = planToSyncPayload(plan, config.deviceId, hostname2, ipAddress);
5234
+ return isLivePlannotatorPayload(payload);
5235
+ });
5236
+ await enqueueChangedPlans(refreshedLive);
5237
+ })().catch((err) => {
5238
+ console.error("[agendex] live session poll failed:", err);
5239
+ });
5240
+ }, LIVE_SESSION_POLL_MS);
5241
+ }
4945
5242
  }
4946
- startWatching((changedPlans) => {
4947
- (async () => {
4948
- const ipAddress = await getSyncIpAddress();
4949
- for (const plan of changedPlans) {
4950
- syncQueue.push(planToSyncPayload(plan, config.deviceId, hostname2, ipAddress));
4951
- }
4952
- processSyncQueue();
4953
- await reconcileLivePlannotatorSessions(getAll());
4954
- })().catch((err) => {
4955
- console.error("[agendex] failed to queue changed plans:", err);
4956
- });
4957
- });
5243
+ startWatching(onPlansFileChange);
4958
5244
  console.log(`[agendex] daemon running. Watching for file changes...`);
4959
5245
  async function gracefulShutdown() {
4960
5246
  stopWatching();
@@ -4981,7 +5267,7 @@ async function startSupervisor() {
4981
5267
  };
4982
5268
  process.on("SIGTERM", shutdown);
4983
5269
  process.on("SIGINT", shutdown);
4984
- const scriptPath = resolve6(process.argv[1] ?? fileURLToPath(new URL("./cli.ts", import.meta.url)));
5270
+ const scriptPath = resolve7(process.argv[1] ?? fileURLToPath(new URL("./cli.ts", import.meta.url)));
4985
5271
  const restartTimes = [];
4986
5272
  while (!stopping) {
4987
5273
  const workerArgs = [scriptPath, "start", "--worker"];
@@ -4991,11 +5277,11 @@ async function startSupervisor() {
4991
5277
  stdio: ["ignore", "inherit", "inherit"],
4992
5278
  env: { ...process.env, ...isDevMode() ? { AGENDEX_DEV: "1" } : {} }
4993
5279
  });
4994
- const exitCode = await new Promise((resolve7) => {
4995
- workerProc?.once("exit", (code) => resolve7(code));
5280
+ const exitCode = await new Promise((resolve8) => {
5281
+ workerProc?.once("exit", (code) => resolve8(code));
4996
5282
  workerProc?.once("error", (error) => {
4997
5283
  console.error("[agendex] failed to spawn worker:", error);
4998
- resolve7(1);
5284
+ resolve8(1);
4999
5285
  });
5000
5286
  });
5001
5287
  workerProc = null;
@@ -5021,7 +5307,7 @@ async function startSupervisor() {
5021
5307
  import { existsSync as existsSync10, readFileSync as readFileSync7 } from "node:fs";
5022
5308
  import { mkdir as mkdir2, rm, writeFile as writeFile4 } from "node:fs/promises";
5023
5309
  import { homedir as homedir10 } from "node:os";
5024
- import { dirname as dirname4, join as join14, resolve as resolve7 } from "node:path";
5310
+ import { dirname as dirname4, join as join14, resolve as resolve8 } from "node:path";
5025
5311
  var SUPPORTED_AGENTS = ["claude-code", "codex", "pi"];
5026
5312
  var MANAGED_MARKER = "agendex-plan-review";
5027
5313
  var HOOK_TIMEOUT_SECONDS = 345600;
@@ -5033,7 +5319,7 @@ function commandFor(cliEntry, agent) {
5033
5319
  return `${shellQuote(process.execPath)} ${shellQuote(cliEntry)} review-plan --hook --agent ${agent}`;
5034
5320
  }
5035
5321
  function scopeRoot(scope) {
5036
- return scope === "repo" ? resolve7(process.env.PWD || process.cwd()) : homedir10();
5322
+ return scope === "repo" ? resolve8(process.env.PWD || process.cwd()) : homedir10();
5037
5323
  }
5038
5324
  function hooksJsonPath(agent, scope) {
5039
5325
  const root = scopeRoot(scope);
@@ -5358,7 +5644,7 @@ async function runHooksCommand(args, cliEntry) {
5358
5644
  for (const agent of agents) {
5359
5645
  if (agent === "claude-code")
5360
5646
  printClaudePreviewWarning(dryRun);
5361
- const path = await installAgent(agent, scope, resolve7(cliEntry), dryRun);
5647
+ const path = await installAgent(agent, scope, resolve8(cliEntry), dryRun);
5362
5648
  console.log(`[agendex] ${dryRun ? "would install" : "installed"} ${agent} hook: ${path}`);
5363
5649
  }
5364
5650
  return 0;
@@ -5448,7 +5734,7 @@ async function syncAll(force = false) {
5448
5734
  // src/upgrade.ts
5449
5735
  import { spawn as spawn3, spawnSync } from "node:child_process";
5450
5736
  import { realpathSync as realpathSync2 } from "node:fs";
5451
- import { dirname as dirname5, resolve as resolve8, sep as sep4 } from "node:path";
5737
+ import { dirname as dirname5, resolve as resolve9, sep as sep4 } from "node:path";
5452
5738
  import { fileURLToPath as fileURLToPath2 } from "node:url";
5453
5739
 
5454
5740
  // src/version.ts
@@ -5458,7 +5744,7 @@ import { join as join15 } from "node:path";
5458
5744
  // package.json
5459
5745
  var package_default = {
5460
5746
  name: "agendex-cli",
5461
- version: "1.1.0",
5747
+ version: "1.3.0",
5462
5748
  description: "Agendex CLI for login, sync, and daemon workflows",
5463
5749
  homepage: "https://github.com/Tyru5/Agendex#readme",
5464
5750
  bugs: {
@@ -5581,9 +5867,9 @@ var PACKAGE_NAME = "agendex-cli";
5581
5867
  var moduleDir = dirname5(fileURLToPath2(import.meta.url));
5582
5868
  function getPackageRoot() {
5583
5869
  try {
5584
- return realpathSync2(resolve8(moduleDir, ".."));
5870
+ return realpathSync2(resolve9(moduleDir, ".."));
5585
5871
  } catch {
5586
- return resolve8(moduleDir, "..");
5872
+ return resolve9(moduleDir, "..");
5587
5873
  }
5588
5874
  }
5589
5875
  function detectPackageManager(packageRoot) {
@@ -5765,9 +6051,120 @@ async function runUpgrade(opts) {
5765
6051
  });
5766
6052
  }
5767
6053
 
6054
+ // src/upload.ts
6055
+ import { readFile as readFile8, stat as stat8 } from "node:fs/promises";
6056
+ import { existsSync as existsSync12 } from "node:fs";
6057
+ import { hostname as osHostname4 } from "node:os";
6058
+ function resolveAgentOverride(args) {
6059
+ const idx = args.indexOf("--agent");
6060
+ if (idx === -1)
6061
+ return;
6062
+ const value = args[idx + 1];
6063
+ if (value === undefined || value.startsWith("--"))
6064
+ return "missing";
6065
+ return value;
6066
+ }
6067
+ function resolvePathArg(args) {
6068
+ for (let i = 0;i < args.length; i++) {
6069
+ const a = args[i];
6070
+ if (a === undefined)
6071
+ continue;
6072
+ if (a === "upload" || a === "--dev" || a === "--open")
6073
+ continue;
6074
+ if (a === "--agent") {
6075
+ i++;
6076
+ continue;
6077
+ }
6078
+ if (a.startsWith("--"))
6079
+ continue;
6080
+ return a;
6081
+ }
6082
+ return;
6083
+ }
6084
+ async function runUpload(args, deps) {
6085
+ const syncPlanFn = deps?.syncPlan ?? syncPlan;
6086
+ const log = deps?.log ?? ((m) => console.log(m));
6087
+ const error = deps?.error ?? ((m) => console.error(m));
6088
+ const openBrowser2 = deps?.openBrowser ?? launchBrowser;
6089
+ const pathArg = resolvePathArg(args);
6090
+ if (!pathArg || !pathArg.trim()) {
6091
+ error("[agendex] usage: agendex upload <path> [--agent <name>] [--open]");
6092
+ return 1;
6093
+ }
6094
+ const absolutePath = resolveCustomPlanDirPath(pathArg);
6095
+ if (!existsSync12(absolutePath)) {
6096
+ error(`[agendex] path does not exist: ${absolutePath}`);
6097
+ return 1;
6098
+ }
6099
+ let stats;
6100
+ try {
6101
+ stats = await stat8(absolutePath);
6102
+ } catch (err) {
6103
+ error(`[agendex] could not read path: ${err instanceof Error ? err.message : String(err)}`);
6104
+ return 1;
6105
+ }
6106
+ if (stats.isDirectory()) {
6107
+ error(`[agendex] path is a directory, expected a single file: ${absolutePath}`);
6108
+ return 1;
6109
+ }
6110
+ if (!/\.md$/i.test(absolutePath)) {
6111
+ error(`[agendex] only Markdown (.md) files are supported: ${absolutePath}`);
6112
+ return 1;
6113
+ }
6114
+ const config = loadConfig();
6115
+ if (!config?.cloudToken || !config?.convexUrl) {
6116
+ error("[agendex] not logged in. Run `agendex login` first.");
6117
+ return 1;
6118
+ }
6119
+ let content;
6120
+ try {
6121
+ content = await readFile8(absolutePath, "utf-8");
6122
+ } catch (err) {
6123
+ error(`[agendex] could not read file: ${err instanceof Error ? err.message : String(err)}`);
6124
+ return 1;
6125
+ }
6126
+ const agentOverride = resolveAgentOverride(args);
6127
+ if (agentOverride === "missing") {
6128
+ error("[agendex] usage: agendex upload <path> [--agent <name>] [--open]");
6129
+ error("[agendex] --agent requires a name");
6130
+ return 1;
6131
+ }
6132
+ const ipAddress = await shouldIncludeLocalIpAddressInSync() ? getLocalIpAddress() : undefined;
6133
+ const payload = fileToSyncPayload(absolutePath, content, {
6134
+ agentOverride,
6135
+ deviceId: config.deviceId ?? loadOrCreateDeviceId(),
6136
+ hostname: osHostname4(),
6137
+ ipAddress,
6138
+ createdAt: stats.birthtime.getTime(),
6139
+ updatedAt: stats.mtime.getTime()
6140
+ });
6141
+ const result = await syncPlanFn(payload);
6142
+ if (!result.ok) {
6143
+ if (result.status === 403) {
6144
+ error(`[agendex] ${result.error ?? "Cloud Pro subscription required"}`);
6145
+ error(`[agendex] View plans and pricing: ${getSiteUrl().replace(/\/$/, "")}/#pricing`);
6146
+ return 1;
6147
+ }
6148
+ error(`[agendex] upload failed: ${result.error ?? "unknown error"}`);
6149
+ return 1;
6150
+ }
6151
+ if (result.skippedLowValue) {
6152
+ log(`[agendex] "${payload.title}" was skipped as a low-value plan and was not stored in the cloud.`);
6153
+ return 0;
6154
+ }
6155
+ const site = getSiteUrl().replace(/\/$/, "");
6156
+ const planUrl = result.planId ? `${site}/dashboard?plan=${encodeURIComponent(result.planId)}` : `${site}/dashboard`;
6157
+ log(`[agendex] uploaded "${payload.title}"`);
6158
+ log(`[agendex] ${planUrl}`);
6159
+ if (args.includes("--open")) {
6160
+ openBrowser2(planUrl, "uploaded plan in your browser");
6161
+ }
6162
+ return 0;
6163
+ }
6164
+
5768
6165
  // src/web.ts
5769
6166
  async function openAgendexWeb(siteUrlOverride) {
5770
- const base = siteUrlOverride ?? getDefaultSiteUrl();
6167
+ const base = siteUrlOverride ?? getSiteUrl();
5771
6168
  const url = base.replace(/\/$/, "");
5772
6169
  launchBrowser(url, "Agendex in your browser");
5773
6170
  }
@@ -5802,7 +6199,7 @@ function firstCommandToken(argv) {
5802
6199
  return;
5803
6200
  }
5804
6201
  var command = firstCommandToken(args) ?? "start";
5805
- var cliEntry = resolve9(process.argv[1] ?? fileURLToPath3(import.meta.url));
6202
+ var cliEntry = resolve10(process.argv[1] ?? fileURLToPath3(import.meta.url));
5806
6203
  async function main() {
5807
6204
  const isInternal = args.includes("--daemon") || args.includes("--worker");
5808
6205
  if (command === "--version" || command === "-v") {
@@ -5822,6 +6219,7 @@ async function main() {
5822
6219
  "add-dir",
5823
6220
  "remove-dir",
5824
6221
  "list-dirs",
6222
+ "upload",
5825
6223
  "upgrade",
5826
6224
  "help",
5827
6225
  "--help",
@@ -5922,6 +6320,9 @@ async function main() {
5922
6320
  await syncAll(force);
5923
6321
  return 0;
5924
6322
  }
6323
+ case "upload": {
6324
+ return await runUpload(args);
6325
+ }
5925
6326
  case "hooks": {
5926
6327
  return await runHooksCommand(args, cliEntry);
5927
6328
  }
@@ -6010,7 +6411,7 @@ async function main() {
6010
6411
  return 1;
6011
6412
  }
6012
6413
  const resolved = resolveCustomPlanDirPath(dirPath);
6013
- if (!existsSync12(resolved)) {
6414
+ if (!existsSync13(resolved)) {
6014
6415
  writeStderr(`[agendex] path does not exist: ${resolved}`);
6015
6416
  return 1;
6016
6417
  }
@@ -6029,7 +6430,7 @@ async function main() {
6029
6430
  const { request } = await import("node:http");
6030
6431
  const body = JSON.stringify({ path: resolved });
6031
6432
  try {
6032
- const res = await new Promise((resolve10, reject) => {
6433
+ const res = await new Promise((resolve11, reject) => {
6033
6434
  const req = request(`http://localhost:${port}/api/v1/plan-sources`, {
6034
6435
  method: "POST",
6035
6436
  headers: {
@@ -6043,7 +6444,7 @@ async function main() {
6043
6444
  res2.on("data", (chunk) => {
6044
6445
  data += chunk;
6045
6446
  });
6046
- res2.on("end", () => resolve10({ status: res2.statusCode ?? 0, body: data }));
6447
+ res2.on("end", () => resolve11({ status: res2.statusCode ?? 0, body: data }));
6047
6448
  res2.on("error", reject);
6048
6449
  });
6049
6450
  req.on("error", reject);
@@ -6203,6 +6604,9 @@ Usage:
6203
6604
  agendex list-dirs List custom plan directories
6204
6605
  agendex sync One-shot scan + sync to cloud (skips unchanged plans)
6205
6606
  agendex sync --force Re-sync all plans, ignoring cache
6607
+ agendex upload <path> Upload a single Markdown plan file to the cloud
6608
+ agendex upload <path> --agent <name> Override the uploaded plan's agent label
6609
+ agendex upload <path> --open Open the uploaded plan in the browser after upload
6206
6610
  agendex cleanup Interactively remove cloud daemons
6207
6611
  agendex cleanup --stale Auto-remove all stale daemons
6208
6612
  agendex status Show current config state + daemon status
@@ -6236,13 +6640,13 @@ function flushStream(stream) {
6236
6640
  if (stream.destroyed || !stream.writable) {
6237
6641
  return Promise.resolve();
6238
6642
  }
6239
- return new Promise((resolve10, reject) => {
6643
+ return new Promise((resolve11, reject) => {
6240
6644
  stream.write("", (error) => {
6241
6645
  if (error) {
6242
6646
  reject(error);
6243
6647
  return;
6244
6648
  }
6245
- resolve10();
6649
+ resolve11();
6246
6650
  });
6247
6651
  });
6248
6652
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agendex-cli",
3
- "version": "1.1.0",
3
+ "version": "1.3.0",
4
4
  "description": "Agendex CLI for login, sync, and daemon workflows",
5
5
  "homepage": "https://github.com/Tyru5/Agendex#readme",
6
6
  "repository": {