@rubytech/taskmaster 1.0.81 → 1.0.82

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.
@@ -132,7 +132,12 @@ export async function ensureAgentWorkspace(params) {
132
132
  .access(bootstrapDonePath)
133
133
  .then(() => true)
134
134
  .catch(() => false);
135
- if (!bootstrapDone) {
135
+ // Only write BOOTSTRAP.md for brand-new workspaces. If other workspace
136
+ // files already exist (IDENTITY.md, SOUL.md, etc.) the workspace has been
137
+ // bootstrapped — don't re-create BOOTSTRAP.md even if .bootstrap-done is
138
+ // missing. This prevents the onboarding flow from re-activating after a
139
+ // restart, session reset, or AI failure to write the sentinel.
140
+ if (!bootstrapDone && isBrandNewWorkspace) {
136
141
  await writeFileIfMissing(bootstrapPath, bootstrapTemplate);
137
142
  }
138
143
  await ensureGitRepo(dir, isBrandNewWorkspace);
@@ -179,8 +184,19 @@ export async function loadWorkspaceBootstrapFiles(dir) {
179
184
  filePath: path.join(resolvedDir, DEFAULT_BOOTSTRAP_FILENAME),
180
185
  },
181
186
  ];
187
+ // If .bootstrap-done exists, treat BOOTSTRAP.md as absent so the onboarding
188
+ // flow is never injected into agent context on a bootstrapped workspace.
189
+ const bootstrapDonePath = path.join(resolvedDir, BOOTSTRAP_DONE_SENTINEL);
190
+ const bootstrapDone = await fs
191
+ .access(bootstrapDonePath)
192
+ .then(() => true)
193
+ .catch(() => false);
182
194
  const result = [];
183
195
  for (const entry of entries) {
196
+ if (bootstrapDone && entry.name === DEFAULT_BOOTSTRAP_FILENAME) {
197
+ result.push({ name: entry.name, path: entry.filePath, missing: true });
198
+ continue;
199
+ }
184
200
  try {
185
201
  const content = await fs.readFile(entry.filePath, "utf-8");
186
202
  result.push({
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.0.81",
3
- "commit": "a402844e35cc24943b6f70f99f815b821447d02e",
4
- "builtAt": "2026-02-20T21:39:25.223Z"
2
+ "version": "1.0.82",
3
+ "commit": "4707adc4b128245c2e265a9e6fec6b2d0315ad08",
4
+ "builtAt": "2026-02-20T22:56:04.720Z"
5
5
  }
@@ -6,9 +6,19 @@ import { ErrorCodes, errorShape } from "../protocol/index.js";
6
6
  const MAX_PREVIEW_BYTES = 256 * 1024; // 256 KB for preview
7
7
  const MAX_DOWNLOAD_BYTES = 5 * 1024 * 1024; // 5 MB for download
8
8
  const MAX_UPLOAD_BYTES = 5 * 1024 * 1024; // 5 MB for upload
9
+ /**
10
+ * Multi-agent workspaces set each agent's workspace to a subdirectory
11
+ * (e.g. ~/taskmaster/agents/admin). The files page should show the
12
+ * workspace root (~/taskmaster), not the agent subdir.
13
+ */
14
+ function stripAgentSubdir(agentWorkspaceDir) {
15
+ const normalised = agentWorkspaceDir.replace(/\/+$/, "");
16
+ const match = normalised.match(/^(.+)\/agents\/[^/]+$/);
17
+ return match ? match[1] : normalised;
18
+ }
9
19
  function resolveWorkspaceRoot() {
10
20
  const cfg = loadConfig();
11
- return resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg));
21
+ return stripAgentSubdir(resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg)));
12
22
  }
13
23
  /**
14
24
  * Resolve workspace root from request params.
@@ -20,7 +30,7 @@ function resolveWorkspaceForRequest(params) {
20
30
  if (!agentId)
21
31
  return resolveWorkspaceRoot();
22
32
  const cfg = loadConfig();
23
- return resolveAgentWorkspaceDir(cfg, agentId);
33
+ return stripAgentSubdir(resolveAgentWorkspaceDir(cfg, agentId));
24
34
  }
25
35
  /**
26
36
  * Validate and resolve a relative path within the workspace.
@@ -4,6 +4,15 @@ import { ErrorCodes, errorShape, formatValidationErrors, validateWebLoginStartPa
4
4
  import { formatForLog } from "../ws-log.js";
5
5
  const WEB_LOGIN_METHODS = new Set(["web.login.start", "web.login.wait"]);
6
6
  const resolveWebLoginProvider = () => listChannelPlugins().find((plugin) => (plugin.gatewayMethods ?? []).some((method) => WEB_LOGIN_METHODS.has(method))) ?? null;
7
+ /**
8
+ * Given an admin agent ID, find the matching public agent.
9
+ * "admin" → "public", "foo-admin" → "foo-public".
10
+ */
11
+ function resolvePublicCounterpart(adminAgentId, agents) {
12
+ const lower = adminAgentId.toLowerCase();
13
+ const target = lower === "admin" ? "public" : adminAgentId.replace(/-admin$/i, "-public");
14
+ return agents.find((a) => (a.id ?? "").toLowerCase() === target.toLowerCase())?.id ?? undefined;
15
+ }
7
16
  /**
8
17
  * After a successful WhatsApp QR pairing, auto-create a paired admin binding
9
18
  * for the self phone number. This is the single code path for all businesses:
@@ -101,9 +110,31 @@ async function ensurePairedAdminBinding(selfPhone, accountId) {
101
110
  },
102
111
  meta: { paired: true },
103
112
  };
113
+ const newBindings = [newBinding];
114
+ // Ensure a channel-level catch-all for the public agent so unbound DMs
115
+ // route to public, not admin. Without this, the routing default fallback
116
+ // sends every unknown phone number to admin.
117
+ const publicAgentId = resolvePublicCounterpart(adminAgentId, agents);
118
+ if (publicAgentId) {
119
+ const hasPublicCatchAll = bindings.some((b) => b.agentId === publicAgentId &&
120
+ b.match.channel === "whatsapp" &&
121
+ !b.match.peer &&
122
+ !b.match.guildId &&
123
+ !b.match.teamId);
124
+ if (!hasPublicCatchAll) {
125
+ newBindings.push({
126
+ agentId: publicAgentId,
127
+ match: {
128
+ channel: "whatsapp",
129
+ ...(accountId ? { accountId } : {}),
130
+ },
131
+ });
132
+ console.log(`[web] ensurePairedAdminBinding: created catch-all binding for ${publicAgentId} (account=${effectiveAccount})`);
133
+ }
134
+ }
104
135
  const updatedCfg = {
105
136
  ...cfg,
106
- bindings: [...bindings, newBinding],
137
+ bindings: [...bindings, ...newBindings],
107
138
  };
108
139
  await writeConfigFile(updatedCfg);
109
140
  console.log(`[web] ensurePairedAdminBinding: created paired binding for ${adminAgentId} → ${selfPhone} (account=${effectiveAccount})`);
@@ -1,4 +1,4 @@
1
- import { resolveDefaultAgentId } from "../agents/agent-scope.js";
1
+ import { resolveDefaultAgentId, listAgentIds } from "../agents/agent-scope.js";
2
2
  import { listBindings } from "./bindings.js";
3
3
  import { buildAgentMainSessionKey, buildAgentPeerSessionKey, DEFAULT_ACCOUNT_ID, DEFAULT_MAIN_KEY, normalizeAgentId, sanitizeAgentId, } from "./session-key.js";
4
4
  export { DEFAULT_ACCOUNT_ID, DEFAULT_AGENT_ID } from "./session-key.js";
@@ -136,5 +136,28 @@ export function resolveAgentRoute(input) {
136
136
  const anyAccountMatch = bindings.find((b) => b.match?.accountId?.trim() === "*" && !b.match?.peer && !b.match?.guildId && !b.match?.teamId);
137
137
  if (anyAccountMatch)
138
138
  return choose(anyAccountMatch.agentId, "binding.channel");
139
- return choose(resolveDefaultAgentId(input.cfg), "default");
139
+ // Admin agents should only be reached via explicit peer binding.
140
+ // When falling to default for a DM, prefer a public counterpart so that
141
+ // unbound phone numbers never route to admin.
142
+ const defaultId = resolveDefaultAgentId(input.cfg);
143
+ if (peer) {
144
+ const publicFallback = resolvePublicFallback(input.cfg, defaultId);
145
+ if (publicFallback)
146
+ return choose(publicFallback, "default");
147
+ }
148
+ return choose(defaultId, "default");
149
+ }
150
+ /**
151
+ * When the default agent is an admin agent and a public counterpart exists,
152
+ * return the public agent ID. This prevents unbound DMs from reaching admin.
153
+ */
154
+ function resolvePublicFallback(cfg, defaultId) {
155
+ const lower = defaultId.toLowerCase();
156
+ const isAdmin = lower === "admin" || lower.endsWith("-admin");
157
+ if (!isAdmin)
158
+ return undefined;
159
+ const allIds = listAgentIds(cfg);
160
+ // "foo-admin" → "foo-public"; "admin" → "public"
161
+ const publicId = lower === "admin" ? "public" : defaultId.replace(/-admin$/i, "-public");
162
+ return allIds.find((id) => id.toLowerCase() === publicId.toLowerCase());
140
163
  }
@@ -107,8 +107,17 @@ async function clearLegacyBaileysAuthState(authDir) {
107
107
  export async function logoutWeb(params) {
108
108
  const runtime = params.runtime ?? defaultRuntime;
109
109
  const resolvedAuthDir = resolveUserPath(params.authDir ?? resolveDefaultWebAuthDir());
110
- const exists = await webAuthExists(resolvedAuthDir);
111
- if (!exists) {
110
+ // Check whether the auth directory exists on disk — not whether creds are
111
+ // valid JSON. Corrupt or partial state must still be cleaned up.
112
+ let dirExists = false;
113
+ try {
114
+ await fs.access(resolvedAuthDir);
115
+ dirExists = true;
116
+ }
117
+ catch {
118
+ // directory doesn't exist
119
+ }
120
+ if (!dirExists) {
112
121
  runtime.log(info("No WhatsApp Web session found; nothing to delete."));
113
122
  return false;
114
123
  }
@@ -79,14 +79,14 @@ export async function startWebLoginWithQr(opts = {}) {
79
79
  message: `WhatsApp is already linked (${who}). Say "relink" if you want a fresh QR.`,
80
80
  };
81
81
  }
82
- // Force mode: clear stale credentials before generating new QR
83
- if (hasWeb && opts.force) {
84
- await logoutWeb({
85
- authDir: account.authDir,
86
- isLegacyAuthDir: account.isLegacyAuthDir,
87
- runtime,
88
- });
89
- }
82
+ // Always clear credentials before generating a new QR. Even when
83
+ // webAuthExists() returns false, corrupt or partial files may remain on disk
84
+ // and cause Baileys to generate QR codes that WhatsApp rejects.
85
+ await logoutWeb({
86
+ authDir: account.authDir,
87
+ isLegacyAuthDir: account.isLegacyAuthDir,
88
+ runtime,
89
+ });
90
90
  const existing = activeLogins.get(account.accountId);
91
91
  if (existing && isLoginFresh(existing) && existing.qrDataUrl) {
92
92
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/taskmaster",
3
- "version": "1.0.81",
3
+ "version": "1.0.82",
4
4
  "description": "AI-powered business assistant for small businesses",
5
5
  "publishConfig": {
6
6
  "access": "public"