@rubytech/taskmaster 1.0.80 → 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.80",
3
- "commit": "4b23b81707c4c708452321ea22668367d4337662",
4
- "builtAt": "2026-02-20T21:20:38.650Z"
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.80",
3
+ "version": "1.0.82",
4
4
  "description": "AI-powered business assistant for small businesses",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -42,6 +42,16 @@ case "$OS" in
42
42
  esac
43
43
  echo "Platform: $PLATFORM ($OS)"
44
44
 
45
+ # ── Helper: run a command as root (uses sudo if not already root) ────────────
46
+
47
+ as_root() {
48
+ if [ "$(id -u)" = "0" ]; then
49
+ "$@"
50
+ else
51
+ sudo "$@"
52
+ fi
53
+ }
54
+
45
55
  # ── Check Node.js ────────────────────────────────────────────────────────────
46
56
 
47
57
  REQUIRED_NODE_MAJOR=22
@@ -68,8 +78,8 @@ install_node() {
68
78
  echo "Installing Node.js v${REQUIRED_NODE_MAJOR}..."
69
79
  if [ "$PLATFORM" = "linux" ]; then
70
80
  if command -v apt-get >/dev/null 2>&1; then
71
- curl -fsSL "https://deb.nodesource.com/setup_${REQUIRED_NODE_MAJOR}.x" | sudo -E bash -
72
- sudo apt-get install -y nodejs
81
+ curl -fsSL "https://deb.nodesource.com/setup_${REQUIRED_NODE_MAJOR}.x" | as_root bash -
82
+ as_root apt-get install -y nodejs
73
83
  else
74
84
  echo "Unsupported Linux package manager. Install Node.js v${REQUIRED_NODE_MAJOR}+ manually."
75
85
  exit 1
@@ -99,11 +109,7 @@ fi
99
109
 
100
110
  echo ""
101
111
  echo "Installing Taskmaster..."
102
- if [ "$(id -u)" = "0" ]; then
103
- npm install -g @rubytech/taskmaster
104
- else
105
- sudo npm install -g @rubytech/taskmaster
106
- fi
112
+ as_root npm install -g @rubytech/taskmaster
107
113
 
108
114
  # Verify
109
115
  if ! command -v taskmaster >/dev/null 2>&1; then
@@ -114,87 +120,87 @@ fi
114
120
 
115
121
  echo "Taskmaster: $(taskmaster --version 2>/dev/null || echo 'installed')"
116
122
 
117
- # ── Run provision ────────────────────────────────────────────────────────────
123
+ # ── Linux platform setup (hostname, mDNS, linger) ───────────────────────────
118
124
 
119
- echo ""
120
- PROVISION_ARGS=""
121
- if [ -n "$PORT" ]; then
122
- PROVISION_ARGS="--port $PORT"
123
- fi
124
-
125
- REAL_USER="${SUDO_USER:-$(whoami)}"
126
125
  MDNS_PORT="${PORT:-18789}"
127
126
 
128
- if [ "$(id -u)" = "0" ] && [ "$REAL_USER" != "root" ]; then
129
- # ── Running as root via sudo ──
130
- # Platform setup needs root. Provision (config, workspace, daemon) needs
131
- # to run as the real user so paths resolve to their home and systemctl
132
- # --user has a D-Bus session.
133
-
134
- if [ "$PLATFORM" = "linux" ]; then
135
- echo "Platform setup..."
127
+ if [ "$PLATFORM" = "linux" ]; then
128
+ echo ""
129
+ echo "Platform setup..."
136
130
 
137
- # Avahi / mDNS
138
- apt-get install -y avahi-daemon avahi-utils >/dev/null 2>&1 \
139
- && echo " avahi-daemon installed" \
140
- || echo " avahi-daemon install failed (continuing)"
131
+ # Avahi / mDNS
132
+ as_root apt-get install -y avahi-daemon avahi-utils >/dev/null 2>&1 \
133
+ && echo " avahi-daemon installed" \
134
+ || echo " avahi-daemon install failed (continuing)"
141
135
 
142
- # Hostname — include port to avoid conflicts with other instances on the network
143
- if [ "$MDNS_PORT" = "18789" ]; then
144
- TM_HOSTNAME="taskmaster"
136
+ # Hostname — include port to avoid conflicts with other instances on the network
137
+ if [ "$MDNS_PORT" = "18789" ]; then
138
+ TM_HOSTNAME="taskmaster"
139
+ else
140
+ TM_HOSTNAME="taskmaster-${MDNS_PORT}"
141
+ fi
142
+ as_root hostnamectl set-hostname "$TM_HOSTNAME" 2>/dev/null \
143
+ && echo " hostname set to '${TM_HOSTNAME}'" \
144
+ || echo " hostname set failed (continuing)"
145
+
146
+ # Ensure /etc/hosts resolves the new hostname (sudo warns otherwise).
147
+ # Raspberry Pi OS uses 127.0.1.1 for the hostname; other distros may use
148
+ # 127.0.0.1. We update an existing 127.0.1.1 line if present (replacing
149
+ # the old hostname like "raspberrypi"), otherwise append a new entry.
150
+ if ! grep -q "$TM_HOSTNAME" /etc/hosts 2>/dev/null; then
151
+ if grep -q "^127\.0\.1\.1" /etc/hosts 2>/dev/null; then
152
+ as_root sed -i "s/^127\.0\.1\.1.*/127.0.1.1\t$TM_HOSTNAME/" /etc/hosts
145
153
  else
146
- TM_HOSTNAME="taskmaster-${MDNS_PORT}"
147
- fi
148
- hostnamectl set-hostname "$TM_HOSTNAME" 2>/dev/null \
149
- && echo " hostname set to '${TM_HOSTNAME}'" \
150
- || echo " hostname set failed (continuing)"
151
-
152
- # Ensure /etc/hosts resolves the new hostname (sudo warns otherwise).
153
- # Raspberry Pi OS uses 127.0.1.1 for the hostname; other distros may use
154
- # 127.0.0.1. We update an existing 127.0.1.1 line if present (replacing
155
- # the old hostname like "raspberrypi"), otherwise append a new entry.
156
- if ! grep -q "$TM_HOSTNAME" /etc/hosts 2>/dev/null; then
157
- if grep -q "^127\.0\.1\.1" /etc/hosts 2>/dev/null; then
158
- sed -i "s/^127\.0\.1\.1.*/127.0.1.1\t$TM_HOSTNAME/" /etc/hosts
159
- else
160
- echo "127.0.1.1 $TM_HOSTNAME" >> /etc/hosts
161
- fi
154
+ echo "127.0.1.1 $TM_HOSTNAME" | as_root tee -a /etc/hosts >/dev/null
162
155
  fi
156
+ fi
163
157
 
164
- # Remove stale avahi service file from previous installs (conflicts with gateway Bonjour)
165
- rm -f /etc/avahi/services/taskmaster.service
158
+ # Remove stale avahi service file from previous installs (conflicts with gateway Bonjour)
159
+ as_root rm -f /etc/avahi/services/taskmaster.service
166
160
 
167
- # Restart avahi so it picks up the new hostname
168
- systemctl restart avahi-daemon 2>/dev/null || true
169
- echo " mDNS: ${TM_HOSTNAME}.local ready"
161
+ # Restart avahi so it picks up the new hostname
162
+ as_root systemctl restart avahi-daemon 2>/dev/null || true
163
+ echo " mDNS: ${TM_HOSTNAME}.local ready"
170
164
 
171
- # Tailscale (for optional internet access via Funnel)
172
- if ! command -v tailscale >/dev/null 2>&1; then
173
- curl -fsSL https://tailscale.com/install.sh | sh >/dev/null 2>&1 \
174
- && echo " tailscale installed" \
175
- || echo " tailscale install failed (continuing)"
176
- else
177
- echo " tailscale already installed"
178
- fi
165
+ # Tailscale (for optional internet access via Funnel)
166
+ if ! command -v tailscale >/dev/null 2>&1; then
167
+ curl -fsSL https://tailscale.com/install.sh | sh >/dev/null 2>&1 \
168
+ && echo " tailscale installed" \
169
+ || echo " tailscale install failed (continuing)"
170
+ else
171
+ echo " tailscale already installed"
172
+ fi
179
173
 
180
- # Enable user services so systemctl --user works after logout
181
- REAL_UID=$(id -u "$REAL_USER")
182
- loginctl enable-linger "$REAL_USER" 2>/dev/null || true
183
- systemctl start "user@${REAL_UID}.service" 2>/dev/null || true
174
+ # Enable user services so systemctl --user works after logout
175
+ REAL_USER="${SUDO_USER:-$(whoami)}"
176
+ REAL_UID=$(id -u "$REAL_USER")
177
+ as_root loginctl enable-linger "$REAL_USER" 2>/dev/null || true
178
+ as_root systemctl start "user@${REAL_UID}.service" 2>/dev/null || true
179
+
180
+ # Wait for user D-Bus session bus
181
+ for _i in $(seq 1 10); do
182
+ [ -S "/run/user/$REAL_UID/bus" ] && break
183
+ sleep 0.5
184
+ done
185
+ fi
184
186
 
185
- # Wait for user D-Bus session bus
186
- for _i in $(seq 1 10); do
187
- [ -S "/run/user/$REAL_UID/bus" ] && break
188
- sleep 0.5
189
- done
190
- fi
187
+ # ── Run provision ────────────────────────────────────────────────────────────
188
+
189
+ echo ""
190
+ PROVISION_ARGS=""
191
+ if [ -n "$PORT" ]; then
192
+ PROVISION_ARGS="--port $PORT"
193
+ fi
191
194
 
192
- # Resolve real user's home
195
+ REAL_USER="${SUDO_USER:-$(whoami)}"
196
+
197
+ if [ "$(id -u)" = "0" ] && [ "$REAL_USER" != "root" ]; then
198
+ # Running as root via sudo — provision must run as the real user so paths
199
+ # resolve to their home and systemctl --user has a D-Bus session.
193
200
  REAL_HOME=$(getent passwd "$REAL_USER" 2>/dev/null | cut -d: -f6)
194
201
  [ -z "$REAL_HOME" ] && REAL_HOME="/home/$REAL_USER"
195
202
  REAL_UID=$(id -u "$REAL_USER")
196
203
 
197
- # Run provision as the real user
198
204
  # shellcheck disable=SC2086
199
205
  sudo -u "$REAL_USER" \
200
206
  HOME="$REAL_HOME" \