drafted 1.14.0 → 1.14.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.
- package/cli/drafted.mjs +56 -56
- package/mcp/server.mjs +129 -60
- package/package.json +1 -1
package/cli/drafted.mjs
CHANGED
|
@@ -1911,89 +1911,89 @@ skillCmd
|
|
|
1911
1911
|
else console.log(`ok\t${id}\t${data.hash}\tpushed ${data.count}${data.stripped ? `, stripped ${data.stripped}` : ''}${gitignored ? ', gitignored .skillinstall/' : ''}`);
|
|
1912
1912
|
});
|
|
1913
1913
|
|
|
1914
|
-
// ──
|
|
1915
|
-
//
|
|
1916
|
-
// agent allowlist
|
|
1917
|
-
function
|
|
1914
|
+
// ── Minions: management seam (parity with the MCP `minion` tool) ──
|
|
1915
|
+
// Minions are checklist-driven intake surfaces. Management is behind the
|
|
1916
|
+
// agent allowlist and scoped to the session's active org.
|
|
1917
|
+
function emitMinionResult(format, obj) {
|
|
1918
1918
|
if (format === 'json') { console.log(JSON.stringify(obj)); return; }
|
|
1919
1919
|
console.log([obj.status, obj.id || '', obj.slug || '', obj.name || '', obj.error || ''].join('\t'));
|
|
1920
1920
|
}
|
|
1921
1921
|
|
|
1922
|
-
async function
|
|
1922
|
+
async function minionSetEnabled(id, enabled, format) {
|
|
1923
1923
|
requireLogin();
|
|
1924
1924
|
const server = getServerUrl().replace(/\/$/, '');
|
|
1925
|
-
const res = await authFetch(`${server}/api/
|
|
1925
|
+
const res = await authFetch(`${server}/api/minions/${encodeURIComponent(id)}`, {
|
|
1926
1926
|
method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled }),
|
|
1927
1927
|
});
|
|
1928
1928
|
const data = await res.json().catch(() => ({}));
|
|
1929
|
-
if (!res.ok) {
|
|
1930
|
-
const c = data.
|
|
1931
|
-
|
|
1929
|
+
if (!res.ok) { emitMinionResult(format, { status: res.status === 404 ? 'not-found' : 'error', error: data.error || `HTTP ${res.status}` }); process.exit(1); }
|
|
1930
|
+
const c = data.minion || {};
|
|
1931
|
+
emitMinionResult(format, { status: 'ok', id: c.id, slug: c.slug, name: c.name });
|
|
1932
1932
|
}
|
|
1933
1933
|
|
|
1934
|
-
const
|
|
1934
|
+
const minionCmd = program.command('minion').description('Minion management (checklist-driven intake surfaces). Requires the agent allowlist.');
|
|
1935
1935
|
|
|
1936
|
-
|
|
1936
|
+
minionCmd
|
|
1937
1937
|
.command('list')
|
|
1938
|
-
.description('List
|
|
1938
|
+
.description('List Minions (scoped to --project or your active project; all org Minions if neither)')
|
|
1939
1939
|
.option('--project <id>', 'project to scope to (defaults to your active project)')
|
|
1940
1940
|
.option('--format <fmt>', 'output format: json or text', 'text')
|
|
1941
1941
|
.action(async (opts) => {
|
|
1942
1942
|
const pid = opts.project || getActiveProject()?.id;
|
|
1943
|
-
const data = await readApiGet('
|
|
1944
|
-
const rows = data.
|
|
1943
|
+
const data = await readApiGet('minion:list', withQuery('/api/minions', { projectId: pid }));
|
|
1944
|
+
const rows = data.minions || [];
|
|
1945
1945
|
if (opts.format === 'json') { console.log(JSON.stringify(rows)); return; }
|
|
1946
1946
|
for (const c of rows) console.log(`${c.id}\t${c.slug}\t${c.enabled ? 'enabled' : 'disabled'}\t${c.name}`);
|
|
1947
1947
|
});
|
|
1948
1948
|
|
|
1949
|
-
|
|
1949
|
+
minionCmd
|
|
1950
1950
|
.command('get <id>')
|
|
1951
|
-
.description('Get a
|
|
1951
|
+
.description('Get a Minion by id')
|
|
1952
1952
|
.option('--format <fmt>', 'output format: json or text', 'text')
|
|
1953
1953
|
.action(async (id, opts) => {
|
|
1954
|
-
const data = await readApiGet('
|
|
1955
|
-
if (opts.format === 'json') { console.log(JSON.stringify(data.
|
|
1956
|
-
const c = data.
|
|
1954
|
+
const data = await readApiGet('minion:get', `/api/minions/${encodeURIComponent(id)}`);
|
|
1955
|
+
if (opts.format === 'json') { console.log(JSON.stringify(data.minion || data)); return; }
|
|
1956
|
+
const c = data.minion || {};
|
|
1957
1957
|
console.log(`${c.id}\t${c.slug}\t${c.enabled ? 'enabled' : 'disabled'}\t${c.name}`);
|
|
1958
1958
|
});
|
|
1959
1959
|
|
|
1960
|
-
|
|
1960
|
+
minionCmd
|
|
1961
1961
|
.command('meta')
|
|
1962
|
-
.description('Project layers/lanes/frames for building a
|
|
1962
|
+
.description('Project layers/lanes/frames for building a Minion target/output')
|
|
1963
1963
|
.option('--project <id>', 'project (defaults to your active project)')
|
|
1964
1964
|
.option('--format <fmt>', 'output format: json or text', 'text')
|
|
1965
1965
|
.action(async (opts) => {
|
|
1966
1966
|
const pid = opts.project || getActiveProject()?.id;
|
|
1967
1967
|
if (!pid) { console.error('No active project. Pass --project <id> or run `drafted use <project>`.'); process.exit(1); }
|
|
1968
|
-
const data = await readApiGet('
|
|
1968
|
+
const data = await readApiGet('minion:meta', withQuery('/api/minions/meta', { projectId: pid }));
|
|
1969
1969
|
console.log(JSON.stringify(data));
|
|
1970
1970
|
});
|
|
1971
1971
|
|
|
1972
|
-
|
|
1972
|
+
minionCmd
|
|
1973
1973
|
.command('create')
|
|
1974
|
-
.description('Create a
|
|
1974
|
+
.description('Create a Minion from stdin JSON {name,description?,target,checklist,output,enabled?}')
|
|
1975
1975
|
.option('--project <id>', 'project to bind to (defaults to your active project)')
|
|
1976
1976
|
.option('--format <fmt>', 'output format: json or text', 'text')
|
|
1977
1977
|
.action(async (opts) => {
|
|
1978
1978
|
requireLogin();
|
|
1979
1979
|
const pid = opts.project || getActiveProject()?.id;
|
|
1980
|
-
if (!pid) {
|
|
1980
|
+
if (!pid) { emitMinionResult(opts.format, { status: 'error', error: 'no project — pass --project <id> or run `drafted use <project>`' }); process.exit(1); }
|
|
1981
1981
|
let p;
|
|
1982
1982
|
try { p = readStdinJSON(); } catch { console.error('invalid JSON on stdin'); process.exit(1); }
|
|
1983
1983
|
const server = getServerUrl().replace(/\/$/, '');
|
|
1984
|
-
const res = await authFetch(`${server}/api/
|
|
1984
|
+
const res = await authFetch(`${server}/api/minions`, {
|
|
1985
1985
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
1986
1986
|
body: JSON.stringify({ projectId: pid, name: p.name, description: p.description, target: p.target, checklist: p.checklist, output: p.output, enabled: p.enabled }),
|
|
1987
1987
|
});
|
|
1988
1988
|
const data = await res.json().catch(() => ({}));
|
|
1989
|
-
if (!res.ok) {
|
|
1990
|
-
const c = data.
|
|
1991
|
-
|
|
1989
|
+
if (!res.ok) { emitMinionResult(opts.format, { status: res.status === 403 ? 'forbidden' : 'error', error: data.error || `HTTP ${res.status}` }); process.exit(1); }
|
|
1990
|
+
const c = data.minion || {};
|
|
1991
|
+
emitMinionResult(opts.format, { status: 'ok', id: c.id, slug: c.slug, name: c.name });
|
|
1992
1992
|
});
|
|
1993
1993
|
|
|
1994
|
-
|
|
1994
|
+
minionCmd
|
|
1995
1995
|
.command('update <id>')
|
|
1996
|
-
.description('Update a
|
|
1996
|
+
.description('Update a Minion from stdin JSON {name?,description?,target?,checklist?,output?,enabled?}')
|
|
1997
1997
|
.option('--format <fmt>', 'output format: json or text', 'text')
|
|
1998
1998
|
.action(async (id, opts) => {
|
|
1999
1999
|
requireLogin();
|
|
@@ -2003,33 +2003,33 @@ collectorCmd
|
|
|
2003
2003
|
for (const k of ['name', 'description', 'target', 'checklist', 'output', 'enabled']) {
|
|
2004
2004
|
if (p[k] !== undefined) body[k] = p[k];
|
|
2005
2005
|
}
|
|
2006
|
-
if (Object.keys(body).length === 0) {
|
|
2006
|
+
if (Object.keys(body).length === 0) { emitMinionResult(opts.format, { status: 'error', error: 'no fields to update' }); process.exit(1); }
|
|
2007
2007
|
const server = getServerUrl().replace(/\/$/, '');
|
|
2008
|
-
const res = await authFetch(`${server}/api/
|
|
2008
|
+
const res = await authFetch(`${server}/api/minions/${encodeURIComponent(id)}`, {
|
|
2009
2009
|
method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
|
|
2010
2010
|
});
|
|
2011
2011
|
const data = await res.json().catch(() => ({}));
|
|
2012
|
-
if (!res.ok) {
|
|
2013
|
-
const c = data.
|
|
2014
|
-
|
|
2012
|
+
if (!res.ok) { emitMinionResult(opts.format, { status: res.status === 404 ? 'not-found' : 'error', error: data.error || `HTTP ${res.status}` }); process.exit(1); }
|
|
2013
|
+
const c = data.minion || {};
|
|
2014
|
+
emitMinionResult(opts.format, { status: 'ok', id: c.id, slug: c.slug, name: c.name });
|
|
2015
2015
|
});
|
|
2016
2016
|
|
|
2017
|
-
|
|
2017
|
+
minionCmd
|
|
2018
2018
|
.command('enable <id>')
|
|
2019
|
-
.description('Make a
|
|
2019
|
+
.description('Make a Minion live')
|
|
2020
2020
|
.option('--format <fmt>', 'output format: json or text', 'text')
|
|
2021
|
-
.action((id, opts) =>
|
|
2021
|
+
.action((id, opts) => minionSetEnabled(id, true, opts.format));
|
|
2022
2022
|
|
|
2023
|
-
|
|
2023
|
+
minionCmd
|
|
2024
2024
|
.command('disable <id>')
|
|
2025
|
-
.description('Take a
|
|
2025
|
+
.description('Take a Minion offline')
|
|
2026
2026
|
.option('--format <fmt>', 'output format: json or text', 'text')
|
|
2027
|
-
.action((id, opts) =>
|
|
2027
|
+
.action((id, opts) => minionSetEnabled(id, false, opts.format));
|
|
2028
2028
|
|
|
2029
|
-
async function
|
|
2029
|
+
async function minionTestPost(id, path, body, format) {
|
|
2030
2030
|
requireLogin();
|
|
2031
2031
|
const server = getServerUrl().replace(/\/$/, '');
|
|
2032
|
-
const res = await authFetch(`${server}/api/
|
|
2032
|
+
const res = await authFetch(`${server}/api/minions/${encodeURIComponent(id)}/${path}`, {
|
|
2033
2033
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body || {}),
|
|
2034
2034
|
});
|
|
2035
2035
|
const data = await res.json().catch(() => ({}));
|
|
@@ -2041,39 +2041,39 @@ async function collectorTestPost(id, path, body, format) {
|
|
|
2041
2041
|
console.log(JSON.stringify(data));
|
|
2042
2042
|
}
|
|
2043
2043
|
|
|
2044
|
-
|
|
2044
|
+
minionCmd
|
|
2045
2045
|
.command('test-start <id>')
|
|
2046
|
-
.description('QA: start (or resume) a test run of a
|
|
2046
|
+
.description('QA: start (or resume) a test run of a Minion you own — works even when disabled. --fresh for a new run.')
|
|
2047
2047
|
.option('--fresh', 'start a brand-new run instead of resuming', false)
|
|
2048
2048
|
.option('--format <fmt>', 'output format: json (default)', 'json')
|
|
2049
|
-
.action((id, opts) =>
|
|
2049
|
+
.action((id, opts) => minionTestPost(id, 'test-start', { fresh: !!opts.fresh }, opts.format));
|
|
2050
2050
|
|
|
2051
|
-
|
|
2051
|
+
minionCmd
|
|
2052
2052
|
.command('test-say <id>')
|
|
2053
2053
|
.description('QA: send a text answer to your test run; returns the agent reply, checklist state, pending actions.')
|
|
2054
2054
|
.requiredOption('--text <text>', 'the consumer message to send')
|
|
2055
2055
|
.option('--format <fmt>', 'output format: json (default)', 'json')
|
|
2056
|
-
.action((id, opts) =>
|
|
2056
|
+
.action((id, opts) => minionTestPost(id, 'test-message', { content: opts.text }, opts.format));
|
|
2057
2057
|
|
|
2058
|
-
|
|
2058
|
+
minionCmd
|
|
2059
2059
|
.command('test-resolve <id>')
|
|
2060
2060
|
.description('QA: approve or reject a pending destructive action in your test run.')
|
|
2061
2061
|
.requiredOption('--action <actionId>', 'the pending action id')
|
|
2062
2062
|
.option('--reject', 'reject instead of approve', false)
|
|
2063
2063
|
.option('--format <fmt>', 'output format: json (default)', 'json')
|
|
2064
|
-
.action((id, opts) =>
|
|
2064
|
+
.action((id, opts) => minionTestPost(id, 'test-resolve', { actionId: opts.action, approve: !opts.reject }, opts.format));
|
|
2065
2065
|
|
|
2066
|
-
|
|
2066
|
+
minionCmd
|
|
2067
2067
|
.command('delete <id>')
|
|
2068
|
-
.description('Delete a
|
|
2068
|
+
.description('Delete a Minion (past submissions are kept as history)')
|
|
2069
2069
|
.option('--format <fmt>', 'output format: json or text', 'text')
|
|
2070
2070
|
.action(async (id, opts) => {
|
|
2071
2071
|
requireLogin();
|
|
2072
2072
|
const server = getServerUrl().replace(/\/$/, '');
|
|
2073
|
-
const res = await authFetch(`${server}/api/
|
|
2073
|
+
const res = await authFetch(`${server}/api/minions/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
|
2074
2074
|
const data = await res.json().catch(() => ({}));
|
|
2075
|
-
if (!res.ok) {
|
|
2076
|
-
|
|
2075
|
+
if (!res.ok) { emitMinionResult(opts.format, { status: res.status === 404 ? 'not-found' : 'error', error: data.error || `HTTP ${res.status}` }); process.exit(1); }
|
|
2076
|
+
emitMinionResult(opts.format, { status: 'ok', id: data.id || id });
|
|
2077
2077
|
});
|
|
2078
2078
|
|
|
2079
2079
|
program.parse();
|
package/mcp/server.mjs
CHANGED
|
@@ -144,7 +144,7 @@ const REMOTE_JSON_STRING_PARAMS = {
|
|
|
144
144
|
wiki: ['frontmatter', 'pages'],
|
|
145
145
|
project: ['layers'],
|
|
146
146
|
template: ['layers'],
|
|
147
|
-
|
|
147
|
+
minion: ['target', 'checklist', 'output'],
|
|
148
148
|
};
|
|
149
149
|
|
|
150
150
|
// Remove sentences that reference local-file params from a tool description,
|
|
@@ -225,6 +225,9 @@ const TOOL_ANNOTATIONS = {
|
|
|
225
225
|
// Identity — read-only introspection of THIS agent's session
|
|
226
226
|
whoami: { title: 'Session identity', readOnlyHint: true, destructiveHint: false, openWorldHint: false, description: 'Return THIS agent session\'s identity: its server-assigned human-readable name (the correlation key between an agent window and its web-app session tab), sessionId, userId, orgId, active projectId, editor label, server URL, and surfaced/alive state. Read-only. Use this — not guesses from the host environment — to report which session you are.' },
|
|
227
227
|
|
|
228
|
+
// Health — call once per session, right after whoami and before any real work
|
|
229
|
+
health: { title: 'Server health', readOnlyHint: true, destructiveHint: false, openWorldHint: true, description: 'Server reachability + installed MCP version/update status. Call this once per session, right after whoami and before doing any real work, so a required update surfaces before you act on stale tool behavior.' },
|
|
230
|
+
|
|
228
231
|
// Projects
|
|
229
232
|
project: { title: 'Projects', readOnlyHint: false, destructiveHint: false, openWorldHint: false, widgetUri: 'ui://widget/drafted-canvas-overview.html', description: 'Manage projects: list (start here), open (bind this agent session to a project — org derives from it), create (org= names where it is born), update, move to another org.' },
|
|
230
233
|
get_org: { title: 'Organization', readOnlyHint: true, destructiveHint: false, openWorldHint: false, description: 'List your orgs, the default org, and Google Drive availability (action="get", default), or fetch installed MCP update instructions (action="update_mcp"). There is no org switching — org derives from the resource you address; creates/searches take org=. When googleDrive.connected is true, strongly prefer Google Workspace frames for documents, sheets, and slides.' },
|
|
@@ -258,8 +261,8 @@ const TOOL_ANNOTATIONS = {
|
|
|
258
261
|
skill: { title: 'Skills', readOnlyHint: false, destructiveHint: true, openWorldHint: false, description: 'Manage the Drafted skill library: search, load, add, update, remove, attach/detach from projects, favorite, and edit skill files.' },
|
|
259
262
|
wiki: { title: 'Wiki', readOnlyHint: false, destructiveHint: true, openWorldHint: false, description: 'Per-org wiki. Markdown pages with paths as hierarchy. Dispatch by `action`.' },
|
|
260
263
|
|
|
261
|
-
//
|
|
262
|
-
|
|
264
|
+
// Minions — checklist-driven intake surfaces bound to a project
|
|
265
|
+
minion: { title: 'Minions', readOnlyHint: false, destructiveHint: true, openWorldHint: false, description: 'Manage Minions: checklist-driven intake surfaces that guide a consumer through a checklist (via a shareable /c/<slug> link) and then write a producible into the project. Dispatch by `action`: meta (discover layers/lanes/frames), list, get, create, update, enable, disable, delete. QA your own Minions with test_start/test_say/test_resolve — drive the checklist conversation yourself (works even when disabled) and verify it produces the right Doc/Sheet output. Requires the agent allowlist.' },
|
|
263
266
|
};
|
|
264
267
|
|
|
265
268
|
function isMutatingToolCall(name, args = {}) {
|
|
@@ -285,7 +288,7 @@ function isMutatingToolCall(name, args = {}) {
|
|
|
285
288
|
return ['add', 'update', 'remove', 'attach', 'detach', 'favorite', 'unfavorite', 'update_file'].includes(action);
|
|
286
289
|
case 'wiki':
|
|
287
290
|
return ['log', 'write', 'edit', 'mv', 'rm', 'source-register', 'bulk-write'].includes(action);
|
|
288
|
-
case '
|
|
291
|
+
case 'minion':
|
|
289
292
|
return ['create', 'update', 'enable', 'disable', 'delete', 'test_start', 'test_say', 'test_resolve'].includes(action);
|
|
290
293
|
case 'rm':
|
|
291
294
|
case 'shape':
|
|
@@ -371,6 +374,10 @@ function tool(name, descOrSchema, schemaOrHandler, handler) {
|
|
|
371
374
|
state.currentTool = name;
|
|
372
375
|
trackUmamiEvent(UMAMI_EVENTS.MCP_TOOL_CALLED, { tool: name, projectId: state.projectId || undefined, source: 'mcp' });
|
|
373
376
|
reportInstallationEvent(UMAMI_EVENTS.DRAFTED_MCP_REQUEST, { tool: name });
|
|
377
|
+
if (!hasAnnouncedSubstantiveWork && !NON_SUBSTANTIVE_TOOLS.has(name)) {
|
|
378
|
+
hasAnnouncedSubstantiveWork = true;
|
|
379
|
+
announceSubstantiveWork().catch(() => {});
|
|
380
|
+
}
|
|
374
381
|
try {
|
|
375
382
|
const requiredUpdateError = await getRequiredMcpUpdateError(name, args?.[0] || {});
|
|
376
383
|
if (requiredUpdateError) return err(new Error(requiredUpdateError));
|
|
@@ -1140,15 +1147,27 @@ function getCurrentProjectContext() {
|
|
|
1140
1147
|
return s.projectMeta || { id: s.projectId, slug: null, name: null, orgId: null };
|
|
1141
1148
|
}
|
|
1142
1149
|
|
|
1150
|
+
function scheduleAgentWsRetry() {
|
|
1151
|
+
clearTimeout(agentWsReconnectTimer);
|
|
1152
|
+
agentWsReconnectTimer = setTimeout(() => {
|
|
1153
|
+
connectAgentWs().catch((e) => {
|
|
1154
|
+
console.error('[MCP-WS] Reconnect failed:', e?.message || e);
|
|
1155
|
+
});
|
|
1156
|
+
}, 5000);
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1143
1159
|
async function connectAgentWs() {
|
|
1144
1160
|
await ensureSession();
|
|
1145
1161
|
const auth = getAuthHeaders();
|
|
1146
|
-
|
|
1162
|
+
// No session yet (e.g. desktop sign-in still in progress) — retry instead of stranding
|
|
1163
|
+
// the handshake forever, since there's no WebSocket here yet to trigger the close-based
|
|
1164
|
+
// reconnect below.
|
|
1165
|
+
if (!auth.Cookie) { scheduleAgentWsRetry(); return; }
|
|
1147
1166
|
|
|
1148
1167
|
const serverUrl = getServerUrl().replace(/^http/, 'ws');
|
|
1149
1168
|
try {
|
|
1150
1169
|
agentWs = new WebSocket(serverUrl, { headers: auth });
|
|
1151
|
-
} catch { return; }
|
|
1170
|
+
} catch { scheduleAgentWsRetry(); return; }
|
|
1152
1171
|
|
|
1153
1172
|
agentWs.on('open', () => {
|
|
1154
1173
|
console.error('[MCP-WS] Connected');
|
|
@@ -1182,12 +1201,7 @@ async function connectAgentWs() {
|
|
|
1182
1201
|
// and survive server restarts, so reusing the clone keeps this agent's surface (and
|
|
1183
1202
|
// its playful tab name) stable across blips — otherwise every reconnect re-clones a
|
|
1184
1203
|
// fresh session → a NEW greyed tab with a NEW name. A real 401 still triggers re-clone.
|
|
1185
|
-
|
|
1186
|
-
agentWsReconnectTimer = setTimeout(() => {
|
|
1187
|
-
connectAgentWs().catch((e) => {
|
|
1188
|
-
console.error('[MCP-WS] Reconnect failed:', e?.message || e);
|
|
1189
|
-
});
|
|
1190
|
-
}, 5000);
|
|
1204
|
+
scheduleAgentWsRetry();
|
|
1191
1205
|
});
|
|
1192
1206
|
|
|
1193
1207
|
agentWs.on('error', () => {
|
|
@@ -1223,6 +1237,28 @@ async function joinAgentWsRoom(projectId) {
|
|
|
1223
1237
|
}
|
|
1224
1238
|
}
|
|
1225
1239
|
|
|
1240
|
+
// Tools that are pure introspection/sign-in, not "the agent started working" — excluded from
|
|
1241
|
+
// the first-substantive-action ping below so a bare whoami/health/auth never yanks the
|
|
1242
|
+
// desktop app's window to the front.
|
|
1243
|
+
const NON_SUBSTANTIVE_TOOLS = new Set(['whoami', 'health', 'auth']);
|
|
1244
|
+
let hasAnnouncedSubstantiveWork = false;
|
|
1245
|
+
|
|
1246
|
+
// Tell the server this agent has started real work (first call past whoami/health/auth this
|
|
1247
|
+
// process), so it can foreground the desktop app's window. Fire-and-forget — never blocks or
|
|
1248
|
+
// fails the tool call that triggered it.
|
|
1249
|
+
async function announceSubstantiveWork() {
|
|
1250
|
+
if (!agentWs || agentWs.readyState > WebSocket.OPEN) {
|
|
1251
|
+
await connectAgentWs();
|
|
1252
|
+
}
|
|
1253
|
+
if (!agentWs) return;
|
|
1254
|
+
const msg = JSON.stringify({ type: 'agent-active', projectId: getState().projectId || null });
|
|
1255
|
+
if (agentWs.readyState === WebSocket.OPEN) {
|
|
1256
|
+
agentWs.send(msg);
|
|
1257
|
+
} else if (agentWs.readyState === WebSocket.CONNECTING) {
|
|
1258
|
+
agentWs.once('open', () => agentWs.send(msg));
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1226
1262
|
// Clone session and connect WebSocket on startup (delayed to let server be ready).
|
|
1227
1263
|
// Guarded because createMcpServer() runs per HTTP request — the bootstrap must
|
|
1228
1264
|
// fire exactly once per process, not per request.
|
|
@@ -1684,11 +1720,35 @@ async function sessionSurfaceBlock() {
|
|
|
1684
1720
|
// Identity: report THIS agent session's own surface identity. Read-only — no state changed.
|
|
1685
1721
|
tool('whoami', 'Return THIS agent session\'s identity: its server-assigned human-readable name (the correlation key between an agent window and its web-app session tab), sessionId, userId, orgId, active projectId, editor label, server URL, and surfaced/alive state. Read-only.', {}, async () => {
|
|
1686
1722
|
try {
|
|
1723
|
+
const block = await sessionSurfaceBlock();
|
|
1724
|
+
// Tell the agent to actually surface its name to the user — returning `name` in the JSON isn't
|
|
1725
|
+
// enough; without an explicit instruction agents rarely say which session they are, so users
|
|
1726
|
+
// can't match them to their tab on the Drafted surface.
|
|
1727
|
+
const instruction = block.name
|
|
1728
|
+
? `You are the session named "${block.name}". Tell the user you're "${block.name}" in your reply so they can match you to your tab on the Drafted surface.`
|
|
1729
|
+
: undefined;
|
|
1687
1730
|
return ok({
|
|
1688
1731
|
server: getServerUrl(),
|
|
1689
1732
|
editor: (process.env.DRAFTED_AGENT_NAME || '').trim() || null,
|
|
1690
1733
|
agentLabel: getAgentLabel(),
|
|
1691
|
-
...
|
|
1734
|
+
...block,
|
|
1735
|
+
...(instruction ? { instruction } : {}),
|
|
1736
|
+
});
|
|
1737
|
+
} catch (error) { return err(error); }
|
|
1738
|
+
});
|
|
1739
|
+
|
|
1740
|
+
// Health: server reachability + installed-MCP staleness, meant to be the second call of a
|
|
1741
|
+
// session (right after whoami, before real work) so a required update surfaces early instead
|
|
1742
|
+
// of depending on an agent remembering to call get_org. Cached per-process — `whoami` stays
|
|
1743
|
+
// network-free between health checks, and repeat `health` calls in one session are free too.
|
|
1744
|
+
tool('health', {}, async () => {
|
|
1745
|
+
try {
|
|
1746
|
+
const mcpUpdate = await getCachedMcpUpdateMetadata();
|
|
1747
|
+
return ok({
|
|
1748
|
+
server: getServerUrl(),
|
|
1749
|
+
ok: mcpUpdate.status !== 'unknown',
|
|
1750
|
+
mcpVersion: PACKAGE_VERSION,
|
|
1751
|
+
mcpUpdate,
|
|
1692
1752
|
});
|
|
1693
1753
|
} catch (error) { return err(error); }
|
|
1694
1754
|
});
|
|
@@ -1705,7 +1765,6 @@ tool('project', 'START HERE for project management. Dispatch by `action`: list (
|
|
|
1705
1765
|
folder: z.string().nullable().optional().describe('[update] folder name (null to remove from folder)'),
|
|
1706
1766
|
layers: z.array(z.object({}).passthrough()).optional().describe('[update] full layers array replacement. Use ls / to read current layers first.'),
|
|
1707
1767
|
targetOrgId: z.string().optional().describe('[move] destination organization ID. Get org IDs from action=list (each project has an orgId field) or get_org. Both source and target org must include the current user.'),
|
|
1708
|
-
skipBrowser: z.boolean().optional().describe('[open] skip opening/navigating a browser tab (use when the user already has the project open, e.g. from an invite snippet)'),
|
|
1709
1768
|
format: z.string().optional().describe('[export] "files" returns {files:[{path,content}]} paginated via limit/offset (compact=true for paths only) instead of writing a local dir (stdio) or returning a download URL (remote).'),
|
|
1710
1769
|
limit: z.number().optional().describe('[export] max files per page for format="files" (default 100, max 500)'),
|
|
1711
1770
|
offset: z.number().optional().describe('[export] pagination offset for format="files"'),
|
|
@@ -1750,7 +1809,7 @@ tool('project', 'START HERE for project management. Dispatch by `action`: list (
|
|
|
1750
1809
|
return ok(data, { structuredContent });
|
|
1751
1810
|
}
|
|
1752
1811
|
case 'open': {
|
|
1753
|
-
const { projectId
|
|
1812
|
+
const { projectId } = args;
|
|
1754
1813
|
if (!projectId) throw new Error('projectId required for action=open');
|
|
1755
1814
|
const result = await api('POST', '/api/project/switch', { projectId });
|
|
1756
1815
|
joinAgentWsRoom(projectId);
|
|
@@ -1767,18 +1826,17 @@ tool('project', 'START HERE for project management. Dispatch by `action`: list (
|
|
|
1767
1826
|
} catch { /* fall back to projectId */ }
|
|
1768
1827
|
setMcpActiveProject(projectId, projectMeta);
|
|
1769
1828
|
const url = `${base}/project/${projectSlug}`;
|
|
1829
|
+
// Surfacing to the user is the focus mechanism's job now (agent-active ping ->
|
|
1830
|
+
// desktop window / notification+glow for browser tabs) — this used to also force
|
|
1831
|
+
// `exec('open <url>')` on the MCP host machine, an unsolicited GUI action that both
|
|
1832
|
+
// duplicated the focus mechanism and did nothing useful for remote/hosted MCP mode
|
|
1833
|
+
// (no GUI to open on Drafted's own server). `url` is still returned below for the
|
|
1834
|
+
// agent/human to open manually.
|
|
1770
1835
|
let navigated = 0;
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
} catch { /* server may not support navigate yet */ }
|
|
1776
|
-
if (navigated === 0) {
|
|
1777
|
-
const { exec } = await import('child_process');
|
|
1778
|
-
const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
1779
|
-
exec(`${cmd} ${JSON.stringify(url)}`);
|
|
1780
|
-
}
|
|
1781
|
-
}
|
|
1836
|
+
try {
|
|
1837
|
+
const nav = await api('POST', '/api/project/navigate', { projectId });
|
|
1838
|
+
navigated = nav.navigated || 0;
|
|
1839
|
+
} catch { /* server may not support navigate yet */ }
|
|
1782
1840
|
// G4/G5 auto-inject (locked design): the project's attached skills + anchors
|
|
1783
1841
|
// are pushed into the open response within the per-project context budget,
|
|
1784
1842
|
// replacing the reject-style gate. Prefer the server-computed `priming`
|
|
@@ -2318,12 +2376,23 @@ async function getMcpUpdateMetadata() {
|
|
|
2318
2376
|
mode,
|
|
2319
2377
|
distribution: mode === 'stdio' ? 'npm-stdio' : 'hosted-http',
|
|
2320
2378
|
update: { command: null, helper: null, packageManager: 'npm' },
|
|
2321
|
-
restart: { required: false, guidance: 'Drafted MCP update status is unavailable;
|
|
2379
|
+
restart: { required: false, guidance: 'Drafted MCP update status is unavailable; this call still succeeded.' },
|
|
2322
2380
|
checkedAt: null,
|
|
2323
2381
|
};
|
|
2324
2382
|
}
|
|
2325
2383
|
}
|
|
2326
2384
|
|
|
2385
|
+
// Process-lifetime cache: `health` is meant to be called every session, so avoid a network
|
|
2386
|
+
// round-trip on repeat calls. `get_org` shares the cache too (same underlying data).
|
|
2387
|
+
let mcpUpdateCache = null; // { data, fetchedAt }
|
|
2388
|
+
const MCP_UPDATE_CACHE_MS = 5 * 60_000;
|
|
2389
|
+
async function getCachedMcpUpdateMetadata() {
|
|
2390
|
+
if (mcpUpdateCache && (Date.now() - mcpUpdateCache.fetchedAt) < MCP_UPDATE_CACHE_MS) return mcpUpdateCache.data;
|
|
2391
|
+
const data = await getMcpUpdateMetadata();
|
|
2392
|
+
mcpUpdateCache = { data, fetchedAt: Date.now() };
|
|
2393
|
+
return data;
|
|
2394
|
+
}
|
|
2395
|
+
|
|
2327
2396
|
|
|
2328
2397
|
tool('get_org', {
|
|
2329
2398
|
action: z.enum(['get', 'update_mcp']).optional().describe('Default: "get" returns your orgs, the default org, and Google Drive availability. Use "update_mcp" to get explicit installed stdio MCP update instructions. There is no org switching: org derives from the resource you address (projectId/pageId/skillId), and creates/searches take an explicit org param.'),
|
|
@@ -2347,7 +2416,7 @@ tool('get_org', {
|
|
|
2347
2416
|
const activeOrg = sessionOrgId ? (orgs.find(o => o.id === sessionOrgId) || null) : null;
|
|
2348
2417
|
|
|
2349
2418
|
const googleDrive = await getGoogleDriveAvailability();
|
|
2350
|
-
const mcpUpdate = await
|
|
2419
|
+
const mcpUpdate = await getCachedMcpUpdateMetadata();
|
|
2351
2420
|
|
|
2352
2421
|
let members = [];
|
|
2353
2422
|
if (sessionOrgId) {
|
|
@@ -4171,51 +4240,51 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
|
|
|
4171
4240
|
} catch (error) { return err(error); }
|
|
4172
4241
|
});
|
|
4173
4242
|
|
|
4174
|
-
// ──
|
|
4243
|
+
// ── Minions ───────────────────────────────────────────────────────
|
|
4175
4244
|
|
|
4176
|
-
function
|
|
4245
|
+
function compactMinionEntry(c) {
|
|
4177
4246
|
if (!c || typeof c !== 'object') return c;
|
|
4178
4247
|
return { id: c.id, slug: c.slug, name: c.name, enabled: c.enabled, projectId: c.projectId };
|
|
4179
4248
|
}
|
|
4180
4249
|
|
|
4181
|
-
// Shape a {
|
|
4250
|
+
// Shape a {minions:[...]} list with limit/offset pagination + optional compact
|
|
4182
4251
|
// mode, mirroring shapeSkillCatalog so large lists stay within token budget.
|
|
4183
|
-
function
|
|
4184
|
-
if (!Array.isArray(result?.
|
|
4185
|
-
const total = result.
|
|
4252
|
+
function shapeMinionList(result, { limit, offset = 0, compact = false } = {}) {
|
|
4253
|
+
if (!Array.isArray(result?.minions)) return result;
|
|
4254
|
+
const total = result.minions.length;
|
|
4186
4255
|
const start = Math.max(0, Math.floor(Number(offset) || 0));
|
|
4187
4256
|
const cap = Math.min(Math.max(1, Math.floor(Number(limit) || 25)), 100);
|
|
4188
|
-
const page = result.
|
|
4257
|
+
const page = result.minions.slice(start, start + cap);
|
|
4189
4258
|
result.totalAvailable = total;
|
|
4190
4259
|
result.offset = start;
|
|
4191
4260
|
result.returned = page.length;
|
|
4192
4261
|
result.truncated = start + page.length < total;
|
|
4193
|
-
result.
|
|
4262
|
+
result.minions = compact ? page.map(compactMinionEntry) : page;
|
|
4194
4263
|
result.note = compact
|
|
4195
|
-
? 'Compact list: {id,slug,name,enabled,projectId} only. Use
|
|
4196
|
-
: '
|
|
4264
|
+
? 'Compact list: {id,slug,name,enabled,projectId} only. Use minion(action="get", id="<id>") for full config; limit/offset to page.'
|
|
4265
|
+
: 'Minions are scoped to the active project (all org Minions when no project is open). Use limit/offset to page; compact=true for a leaner list.';
|
|
4197
4266
|
return result;
|
|
4198
4267
|
}
|
|
4199
4268
|
|
|
4200
4269
|
// Friendlier message when the agent allowlist gate (requireAgentAccess) rejects.
|
|
4201
|
-
function
|
|
4270
|
+
function minionGateError(e) {
|
|
4202
4271
|
if (e?.status === 403 || e?.code === 'agent_disabled') {
|
|
4203
|
-
return new Error('
|
|
4272
|
+
return new Error('Minion management is not enabled for this org/account (agent allowlist). Ask an admin to add your org or email to DRAFTED_AGENT_ALLOWED_ORGS / DRAFTED_AGENT_ALLOWED_EMAILS.');
|
|
4204
4273
|
}
|
|
4205
4274
|
return e;
|
|
4206
4275
|
}
|
|
4207
4276
|
|
|
4208
|
-
tool('
|
|
4209
|
-
action: z.enum(['meta', 'list', 'get', 'create', 'update', 'enable', 'disable', 'delete', 'test_start', 'test_say', 'test_resolve']).describe('Operation to perform. test_* drive a QA conversation against a
|
|
4210
|
-
id: z.string().optional().describe('[get|update|enable|disable|delete|test_*]
|
|
4277
|
+
tool('minion', {
|
|
4278
|
+
action: z.enum(['meta', 'list', 'get', 'create', 'update', 'enable', 'disable', 'delete', 'test_start', 'test_say', 'test_resolve']).describe('Operation to perform. test_* drive a QA conversation against a Minion you own (even disabled) to verify it end-to-end.'),
|
|
4279
|
+
id: z.string().optional().describe('[get|update|enable|disable|delete|test_*] Minion ID (UUID)'),
|
|
4211
4280
|
text: z.string().optional().describe('[test_say] the consumer message to send to your test run'),
|
|
4212
4281
|
fresh: z.boolean().optional().describe('[test_start] start a brand-new run instead of resuming your latest'),
|
|
4213
4282
|
actionId: z.string().optional().describe('[test_resolve] id of the pending action to resolve'),
|
|
4214
4283
|
approve: z.boolean().optional().describe('[test_resolve] approve (default true) or reject the pending action'),
|
|
4215
4284
|
projectId: z.string().optional().describe('[create|meta] project to bind/scope to (defaults to the active project). The org derives from this project — open the target project first via project(action="open") if none is active.'),
|
|
4216
|
-
name: z.string().optional().describe('[create|update]
|
|
4285
|
+
name: z.string().optional().describe('[create|update] Minion name'),
|
|
4217
4286
|
description: z.string().optional().describe('[create|update] one-line description shown to the consumer'),
|
|
4218
|
-
enabled: z.boolean().optional().describe('[create|update] whether the
|
|
4287
|
+
enabled: z.boolean().optional().describe('[create|update] whether the Minion is live; a disabled Minion 404s on its /c/<slug> link. enable/disable set this directly.'),
|
|
4219
4288
|
target: z.object({
|
|
4220
4289
|
type: z.enum(['new-record', 'layer', 'frame']).describe('what the run writes against'),
|
|
4221
4290
|
layer: z.string().optional(),
|
|
@@ -4244,7 +4313,7 @@ tool('collector', {
|
|
|
4244
4313
|
}).optional().describe('[create|update] where/how the producible lands'),
|
|
4245
4314
|
limit: z.number().optional().describe('[list] max results per page (default 25, max 100)'),
|
|
4246
4315
|
offset: z.number().optional().describe('[list] skip N results for pagination (default 0)'),
|
|
4247
|
-
compact: z.boolean().optional().describe('[list] return only {id,slug,name,enabled,projectId} per
|
|
4316
|
+
compact: z.boolean().optional().describe('[list] return only {id,slug,name,enabled,projectId} per Minion'),
|
|
4248
4317
|
}, async (args) => {
|
|
4249
4318
|
try {
|
|
4250
4319
|
const { action } = args;
|
|
@@ -4254,18 +4323,18 @@ tool('collector', {
|
|
|
4254
4323
|
const pid = active || args.projectId;
|
|
4255
4324
|
if (!pid) throw new Error('No active project. Call project(action="open") first, or pass projectId.');
|
|
4256
4325
|
// api() auto-appends the active project; add projectId explicitly only when none is active.
|
|
4257
|
-
const path = active ? '/api/
|
|
4326
|
+
const path = active ? '/api/minions/meta' : `/api/minions/meta?projectId=${encodeURIComponent(pid)}`;
|
|
4258
4327
|
return ok(await api('GET', path));
|
|
4259
4328
|
}
|
|
4260
4329
|
case 'list': {
|
|
4261
4330
|
// api() auto-appends the active project as ?projectId — so this lists the
|
|
4262
|
-
// active project's
|
|
4263
|
-
const result = await api('GET', '/api/
|
|
4264
|
-
return ok(
|
|
4331
|
+
// active project's Minions, or all org Minions when none is open.
|
|
4332
|
+
const result = await api('GET', '/api/minions');
|
|
4333
|
+
return ok(shapeMinionList(result, { limit: args.limit, offset: args.offset, compact: args.compact }));
|
|
4265
4334
|
}
|
|
4266
4335
|
case 'get': {
|
|
4267
4336
|
if (!args.id) throw new Error('id is required for action=get');
|
|
4268
|
-
return ok(await api('GET', `/api/
|
|
4337
|
+
return ok(await api('GET', `/api/minions/${args.id}`));
|
|
4269
4338
|
}
|
|
4270
4339
|
case 'create': {
|
|
4271
4340
|
// projectId lives in the BODY (the POST route reads body, ignores the query).
|
|
@@ -4278,7 +4347,7 @@ tool('collector', {
|
|
|
4278
4347
|
const body = { projectId, name, target, checklist, output };
|
|
4279
4348
|
if (description !== undefined) body.description = description;
|
|
4280
4349
|
if (enabled !== undefined) body.enabled = enabled;
|
|
4281
|
-
return ok(await api('POST', '/api/
|
|
4350
|
+
return ok(await api('POST', '/api/minions', body));
|
|
4282
4351
|
}
|
|
4283
4352
|
case 'update': {
|
|
4284
4353
|
if (!args.id) throw new Error('id is required for action=update');
|
|
@@ -4287,35 +4356,35 @@ tool('collector', {
|
|
|
4287
4356
|
if (args[k] !== undefined) body[k] = args[k];
|
|
4288
4357
|
}
|
|
4289
4358
|
if (Object.keys(body).length === 0) throw new Error('At least one field is required for action=update');
|
|
4290
|
-
return ok(await api('PATCH', `/api/
|
|
4359
|
+
return ok(await api('PATCH', `/api/minions/${args.id}`, body));
|
|
4291
4360
|
}
|
|
4292
4361
|
case 'enable':
|
|
4293
4362
|
case 'disable': {
|
|
4294
4363
|
if (!args.id) throw new Error(`id is required for action=${action}`);
|
|
4295
|
-
return ok(await api('PATCH', `/api/
|
|
4364
|
+
return ok(await api('PATCH', `/api/minions/${args.id}`, { enabled: action === 'enable' }));
|
|
4296
4365
|
}
|
|
4297
4366
|
case 'delete': {
|
|
4298
4367
|
if (!args.id) throw new Error('id is required for action=delete');
|
|
4299
|
-
return ok(await api('DELETE', `/api/
|
|
4368
|
+
return ok(await api('DELETE', `/api/minions/${args.id}`));
|
|
4300
4369
|
}
|
|
4301
4370
|
case 'test_start': {
|
|
4302
4371
|
if (!args.id) throw new Error('id is required for action=test_start');
|
|
4303
|
-
return ok(await api('POST', `/api/
|
|
4372
|
+
return ok(await api('POST', `/api/minions/${args.id}/test-start`, { fresh: !!args.fresh }));
|
|
4304
4373
|
}
|
|
4305
4374
|
case 'test_say': {
|
|
4306
4375
|
if (!args.id) throw new Error('id is required for action=test_say');
|
|
4307
4376
|
if (!args.text) throw new Error('text is required for action=test_say');
|
|
4308
|
-
return ok(await api('POST', `/api/
|
|
4377
|
+
return ok(await api('POST', `/api/minions/${args.id}/test-message`, { content: args.text }));
|
|
4309
4378
|
}
|
|
4310
4379
|
case 'test_resolve': {
|
|
4311
4380
|
if (!args.id) throw new Error('id is required for action=test_resolve');
|
|
4312
4381
|
if (!args.actionId) throw new Error('actionId is required for action=test_resolve');
|
|
4313
|
-
return ok(await api('POST', `/api/
|
|
4382
|
+
return ok(await api('POST', `/api/minions/${args.id}/test-resolve`, { actionId: args.actionId, approve: args.approve !== false }));
|
|
4314
4383
|
}
|
|
4315
4384
|
default:
|
|
4316
|
-
throw new Error(`Unknown
|
|
4385
|
+
throw new Error(`Unknown minion action: ${action}`);
|
|
4317
4386
|
}
|
|
4318
|
-
} catch (error) { return err(
|
|
4387
|
+
} catch (error) { return err(minionGateError(error)); }
|
|
4319
4388
|
});
|
|
4320
4389
|
|
|
4321
4390
|
// ── Resource: canvas info ─────────────────────────────────────────
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "drafted",
|
|
3
|
-
"version": "1.14.
|
|
3
|
+
"version": "1.14.2",
|
|
4
4
|
"description": "Drafted — visual thinking surface for humans and AI agents. Renders HTML, markdown, images, and code as frames on a zoomable canvas, with MCP tools for AI agents and real-time sync for humans.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|