wisp-agents 0.16.0 → 0.18.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.
- package/CHANGELOG.md +38 -1
- package/README.md +6 -5
- package/bin/wisp.js +4 -2
- package/package.json +2 -2
- package/server/adapters/antigravity.js +5 -1
- package/server/adapters/claude.js +48 -5
- package/server/adapters/codex.js +3 -0
- package/server/adapters/cursor.js +2 -0
- package/server/adapters/grok.js +15 -0
- package/server/adapters/index.js +9 -1
- package/server/adapters/opencode.js +31 -6
- package/server/client.js +1 -1
- package/server/git.js +114 -2
- package/server/handoff.js +2 -1
- package/server/machines.js +2 -2
- package/server/mcp-ask.js +113 -8
- package/server/mentions.js +39 -0
- package/server/openapi.js +21 -3
- package/server/protocol.js +8 -1
- package/server/render.js +3 -1
- package/server/routes/internal.js +121 -1
- package/server/routes/meta.js +1 -1
- package/server/routes/sessions.js +12 -0
- package/server/routes/settings.js +51 -4
- package/server/routes/ship.js +24 -2
- package/server/runner.js +142 -22
- package/server/services/auto.js +12 -1
- package/server/services/launch.js +12 -8
- package/server/services/phone.js +1 -1
- package/server/services/ship.js +83 -4
- package/server/store.js +23 -2
- package/server/triggers.js +1 -1
- package/server/usage.js +94 -0
- package/server/welcome.js +2 -2
- package/web/css/composer.css +22 -0
- package/web/css/feed.css +27 -0
- package/web/css/launcher.css +17 -0
- package/web/css/layout.css +10 -2
- package/web/css/modals.css +8 -0
- package/web/css/palette.css +22 -0
- package/web/css/ship.css +42 -2
- package/web/index.html +16 -5
- package/web/js/app.js +14 -3
- package/web/js/blocks.js +46 -0
- package/web/js/core/state.js +2 -1
- package/web/js/features/actions.js +9 -4
- package/web/js/features/automations.js +1 -1
- package/web/js/features/clones.js +79 -0
- package/web/js/features/compare.js +2 -1
- package/web/js/features/composer.js +11 -7
- package/web/js/features/feed.js +3 -3
- package/web/js/features/launcher.js +150 -28
- package/web/js/features/live.js +3 -3
- package/web/js/features/mentions.js +125 -0
- package/web/js/features/notify.js +1 -1
- package/web/js/features/project-list.js +99 -0
- package/web/js/features/projects.js +218 -34
- package/web/js/features/runners.js +3 -1
- package/web/js/features/settings.js +16 -2
- package/web/js/features/setup.js +3 -3
- package/web/js/features/ship.js +100 -24
- package/web/js/features/turns.js +44 -13
- package/web/js/mention-text.js +59 -0
package/server/machines.js
CHANGED
|
@@ -500,7 +500,7 @@ export class Machines extends EventEmitter {
|
|
|
500
500
|
summary(mid, s) {
|
|
501
501
|
const m = this.get(mid);
|
|
502
502
|
const q = (id) => qualify(id, mid);
|
|
503
|
-
const p = this.store.projectFor(s.cwd || '', mid);
|
|
503
|
+
const p = s.scratch ? null : this.store.projectFor(s.cwd || '', mid);
|
|
504
504
|
return {
|
|
505
505
|
...s,
|
|
506
506
|
id: q(s.id),
|
|
@@ -578,7 +578,7 @@ export class Machines extends EventEmitter {
|
|
|
578
578
|
}
|
|
579
579
|
|
|
580
580
|
async addProject(mid, dir, name) {
|
|
581
|
-
const p = await this.json(mid, 'POST', '/projects', { path: dir });
|
|
581
|
+
const p = await this.json(mid, 'POST', '/projects', { path: dir }, { timeout: 10 * 60_000 }); // dir may be a repo URL to clone
|
|
582
582
|
const local = this.store.addProject(p.path, { name: name || p.name, machine: mid });
|
|
583
583
|
return local;
|
|
584
584
|
}
|
package/server/mcp-ask.js
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// Minimal stdio MCP server exposing `ask_user`, injected into every agent wisp launches.
|
|
2
|
+
// Minimal stdio MCP server exposing `ask_user`, `wisp_stats`, `wisp_sessions` and `wisp_session`, injected into every agent wisp launches.
|
|
3
3
|
// The call is relayed to the wisp server, which surfaces it in the queue and blocks
|
|
4
4
|
// (by polling) until the user answers — so the agent keeps its full context.
|
|
5
5
|
// With a team (WISP_TEAM), it also offers `delegate` and `subagent_results`: tasks for the
|
|
6
6
|
// subagents the user picked, which wisp runs as child sessions while this call waits.
|
|
7
|
+
// With WISP_APPROVE, it offers `approve`: the harness's permission prompt (Claude's
|
|
8
|
+
// --permission-prompt-tool), answered by the user in wisp.
|
|
9
|
+
// `wisp_stats` is read-only: tool counts, spend and success rate across the user's wisp sessions.
|
|
10
|
+
// `wisp_sessions` and `wisp_session` are read-only too: list the user's other tasks and read one's
|
|
11
|
+
// reports, e.g. a task the user mentioned in the prompt.
|
|
7
12
|
|
|
8
13
|
import readline from 'node:readline';
|
|
9
14
|
|
|
10
|
-
const { WISP_URL, WISP_TOKEN, WISP_SESSION } = process.env;
|
|
15
|
+
const { WISP_URL, WISP_TOKEN, WISP_SESSION, WISP_APPROVE } = process.env;
|
|
11
16
|
let TEAM = [];
|
|
12
17
|
try {
|
|
13
18
|
TEAM = JSON.parse(process.env.WISP_TEAM || '[]');
|
|
@@ -30,6 +35,83 @@ const TOOL = {
|
|
|
30
35
|
},
|
|
31
36
|
};
|
|
32
37
|
|
|
38
|
+
// Claude's permission-prompt contract: { tool_name, input, tool_use_id } in, a JSON
|
|
39
|
+
// { behavior: 'allow', updatedInput } or { behavior: 'deny', message } back as text.
|
|
40
|
+
const APPROVE = {
|
|
41
|
+
name: 'approve',
|
|
42
|
+
description: 'Internal: wisp asks the user to approve a tool call your permissions do not allow on their own. Never call this yourself.',
|
|
43
|
+
inputSchema: {
|
|
44
|
+
type: 'object',
|
|
45
|
+
properties: { tool_name: { type: 'string' }, input: { type: 'object' }, tool_use_id: { type: 'string' } },
|
|
46
|
+
required: ['tool_name'],
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const STATS = {
|
|
51
|
+
name: 'wisp_stats',
|
|
52
|
+
description:
|
|
53
|
+
'Read-only stats about the user\'s past wisp tasks: how many tool calls each kind of action made (run = shell, read, edit, ' +
|
|
54
|
+
'search, web, agent, tool = other MCP tools, ask), broken down by harness, project and day, plus turns, cost, tokens and ' +
|
|
55
|
+
'success rate. Use when the user asks about their wisp usage, which tools or harnesses their tasks use, or what tasks cost.',
|
|
56
|
+
inputSchema: {
|
|
57
|
+
type: 'object',
|
|
58
|
+
properties: {
|
|
59
|
+
days: { type: 'number', description: 'How many days back, 1–365. Default 30.' },
|
|
60
|
+
agent: { type: 'string', description: 'Only this harness, e.g. claude, codex, opencode.' },
|
|
61
|
+
project: { type: 'string', description: 'Only this project (name or id); "current" = the project of this task.' },
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const SESSIONS = {
|
|
67
|
+
name: 'wisp_sessions',
|
|
68
|
+
description:
|
|
69
|
+
'List the user\'s other wisp tasks, newest first: id, title, state (running | waiting | review | done), harness, project, ' +
|
|
70
|
+
'last update and a one-line summary. Read-only. Use it to find a task the user refers to; read one with wisp_session.',
|
|
71
|
+
inputSchema: {
|
|
72
|
+
type: 'object',
|
|
73
|
+
properties: {
|
|
74
|
+
query: { type: 'string', description: 'Only tasks whose title or summary has all these words.' },
|
|
75
|
+
state: { type: 'string', description: 'Comma-separated, e.g. "running,waiting".' },
|
|
76
|
+
project: { type: 'string', description: 'Only this project (name or id); "current" = the project of this task.' },
|
|
77
|
+
archived: { type: 'boolean', description: 'Include archived tasks. Default false.' },
|
|
78
|
+
limit: { type: 'number', description: 'Default 20, at most 100.' },
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const SESSION = {
|
|
84
|
+
name: 'wisp_session',
|
|
85
|
+
description:
|
|
86
|
+
'Read another wisp task by id: its title, state, harness, folder and branch, every request the user made in it, and a ' +
|
|
87
|
+
'turn\'s report as markdown (summary, diffs, tests, next steps). Read-only. Use it when the prompt mentions a wisp task ' +
|
|
88
|
+
'("@Title (wisp task <id>)") or the user asks what another task did.',
|
|
89
|
+
inputSchema: {
|
|
90
|
+
type: 'object',
|
|
91
|
+
properties: {
|
|
92
|
+
id: { type: 'string', description: 'The task id.' },
|
|
93
|
+
turn: { type: 'number', description: 'Which turn\'s report, 1 = first. Default: the latest.' },
|
|
94
|
+
},
|
|
95
|
+
required: ['id'],
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
async function sessions(args = {}) {
|
|
100
|
+
const q = new URLSearchParams(Object.entries(args).filter(([, v]) => v !== undefined && v !== '').map(([k, v]) => [k, String(v)]));
|
|
101
|
+
return (await call(`/api/internal/sessions/${WISP_SESSION}?${q}`)).text;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function session({ id, turn } = {}) {
|
|
105
|
+
if (!id) throw new Error('id is required');
|
|
106
|
+
const q = new URLSearchParams({ of: String(id), ...(turn !== undefined && { turn: String(turn) }) });
|
|
107
|
+
return (await call(`/api/internal/session/${WISP_SESSION}?${q}`)).text;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function stats({ days, agent, project } = {}) {
|
|
111
|
+
const q = new URLSearchParams(Object.entries({ days, agent, project }).filter(([, v]) => v !== undefined && v !== ''));
|
|
112
|
+
return (await call(`/api/internal/stats/${WISP_SESSION}?${q}`)).text;
|
|
113
|
+
}
|
|
114
|
+
|
|
33
115
|
const roster = TEAM.map((m) => `- ${m.name}${m.count > 1 ? ` ×${m.count} (parallel copies ${m.copies.join(', ')})` : ''}: ${[m.agent, m.model || 'default model', m.effort && `${m.effort} thinking`].filter(Boolean).join(' · ')}${m.use ? ` — use for: ${m.use}` : ''}`).join('\n');
|
|
34
116
|
const TEAM_TOOLS = [
|
|
35
117
|
{
|
|
@@ -104,8 +186,8 @@ async function call(path, body) {
|
|
|
104
186
|
return res.json();
|
|
105
187
|
}
|
|
106
188
|
|
|
107
|
-
|
|
108
|
-
|
|
189
|
+
// Poll a question until it's answered ({ answer, … }) or cancelled (null).
|
|
190
|
+
async function waitFor(id) {
|
|
109
191
|
for (;;) {
|
|
110
192
|
await sleep(1500);
|
|
111
193
|
let r;
|
|
@@ -114,11 +196,24 @@ async function askUser(args) {
|
|
|
114
196
|
} catch {
|
|
115
197
|
continue; // wisp restarting — keep waiting
|
|
116
198
|
}
|
|
117
|
-
if (r.answer !== undefined) return r
|
|
199
|
+
if (r.answer !== undefined) return r;
|
|
118
200
|
if (r.cancelled) return null;
|
|
119
201
|
}
|
|
120
202
|
}
|
|
121
203
|
|
|
204
|
+
async function askUser(args) {
|
|
205
|
+
const { id } = await call(`/api/internal/ask`, { session: WISP_SESSION, ...args });
|
|
206
|
+
return (await waitFor(id))?.answer ?? null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function approve({ tool_name: tool, input = {} }) {
|
|
210
|
+
let r = await call(`/api/internal/approve`, { session: WISP_SESSION, tool, input });
|
|
211
|
+
if (r.id) r = await waitFor(r.id);
|
|
212
|
+
if (r?.decision === 'allow') return { behavior: 'allow', updatedInput: input };
|
|
213
|
+
if (!r) return { behavior: 'deny', message: 'The user did not answer (the request was cancelled). Do not retry it; carry on without it and say what was skipped.' };
|
|
214
|
+
return { behavior: 'deny', message: r.message ? `The user denied this and said: ${r.message}` : 'The user denied this. Do not retry it; carry on without it or ask how to proceed.' };
|
|
215
|
+
}
|
|
216
|
+
|
|
122
217
|
readline.createInterface({ input: process.stdin }).on('line', async (line) => {
|
|
123
218
|
let msg;
|
|
124
219
|
try {
|
|
@@ -133,11 +228,19 @@ readline.createInterface({ input: process.stdin }).on('line', async (line) => {
|
|
|
133
228
|
if (method === 'initialize') {
|
|
134
229
|
send({ id, result: { protocolVersion: params?.protocolVersion || '2025-06-18', capabilities: { tools: {} }, serverInfo: { name: 'wisp', version: '0.1.0' } } });
|
|
135
230
|
} else if (method === 'tools/list') {
|
|
136
|
-
send({ id, result: { tools:
|
|
231
|
+
send({ id, result: { tools: [TOOL, STATS, SESSIONS, SESSION, ...(WISP_APPROVE ? [APPROVE] : []), ...(TEAM.length ? TEAM_TOOLS : [])] } });
|
|
137
232
|
} else if (method === 'tools/call' && params?.name === 'ask_user') {
|
|
138
233
|
const answer = await askUser(params.arguments || {});
|
|
139
234
|
const text = answer === null ? 'The user did not answer (the question was cancelled). Use your best judgment and note the assumption.' : `The user answered: ${answer}`;
|
|
140
235
|
send({ id, result: { content: [{ type: 'text', text }] } });
|
|
236
|
+
} else if (method === 'tools/call' && WISP_APPROVE && params?.name === 'approve') {
|
|
237
|
+
send({ id, result: { content: [{ type: 'text', text: JSON.stringify(await approve(params.arguments || {})) }] } });
|
|
238
|
+
} else if (method === 'tools/call' && params?.name === 'wisp_stats') {
|
|
239
|
+
send({ id, result: { content: [{ type: 'text', text: await stats(params.arguments || {}) }] } });
|
|
240
|
+
} else if (method === 'tools/call' && params?.name === 'wisp_sessions') {
|
|
241
|
+
send({ id, result: { content: [{ type: 'text', text: await sessions(params.arguments || {}) }] } });
|
|
242
|
+
} else if (method === 'tools/call' && params?.name === 'wisp_session') {
|
|
243
|
+
send({ id, result: { content: [{ type: 'text', text: await session(params.arguments || {}) }] } });
|
|
141
244
|
} else if (method === 'tools/call' && TEAM.length && params?.name === 'delegate') {
|
|
142
245
|
send({ id, result: { content: [{ type: 'text', text: await delegate(params.arguments || {}) }] } });
|
|
143
246
|
} else if (method === 'tools/call' && TEAM.length && params?.name === 'subagent_results') {
|
|
@@ -149,7 +252,9 @@ readline.createInterface({ input: process.stdin }).on('line', async (line) => {
|
|
|
149
252
|
send({ id, error: { code: -32601, message: `Method not found: ${method}` } });
|
|
150
253
|
}
|
|
151
254
|
} catch (err) {
|
|
152
|
-
const what = params?.name === 'ask_user' ? 'wisp could not reach the user' : 'wisp';
|
|
153
|
-
|
|
255
|
+
const what = params?.name === 'ask_user' || params?.name === 'approve' ? 'wisp could not reach the user' : 'wisp';
|
|
256
|
+
// A permission prompt that errors would leave the harness guessing: deny, and say why.
|
|
257
|
+
if (params?.name === 'approve') send({ id, result: { content: [{ type: 'text', text: JSON.stringify({ behavior: 'deny', message: `${what}: ${err.message}` }) }] } });
|
|
258
|
+
else send({ id, result: { isError: true, content: [{ type: 'text', text: `${what}: ${err.message}` }] } });
|
|
154
259
|
}
|
|
155
260
|
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Task mentions: a prompt can point at another wisp task. The web app stores one as
|
|
2
|
+
// `@[Title](wisp:<id>)` (pasting a copied task id in a prompt box makes one); the agent gets
|
|
3
|
+
// `@Title (wisp task <id>)` plus a note on how to read it: the wisp_session tool, or the task's
|
|
4
|
+
// report inline for runs that can't take wisp's MCP tools.
|
|
5
|
+
|
|
6
|
+
import { sessionToMarkdown } from './render.js';
|
|
7
|
+
import { truncate } from './util.js';
|
|
8
|
+
|
|
9
|
+
export const MENTION = /@\[([^\]\n]{1,200})\]\(wisp:([a-z0-9]{6,40})\)/g;
|
|
10
|
+
const INLINE_MAX = 8_000; // characters of a mentioned task's report, when it goes in the prompt
|
|
11
|
+
|
|
12
|
+
export const mentionsIn = (text = '') => [...new Map([...String(text).matchAll(MENTION)].map((m) => [m[2], { id: m[2], title: m[1] }])).values()];
|
|
13
|
+
|
|
14
|
+
// What a person reads: titles, no ids (session titles, notifications).
|
|
15
|
+
export const plainMentions = (text = '') => String(text).replace(MENTION, '@$1');
|
|
16
|
+
|
|
17
|
+
// What an agent reads in place of each mention.
|
|
18
|
+
export const textMentions = (text = '') => String(text).replace(MENTION, '@$1 (wisp task $2)');
|
|
19
|
+
|
|
20
|
+
// The prompt for an agent run. tool: how this harness names the wisp_session tool, or null when
|
|
21
|
+
// it has none (then each mentioned task's latest report goes in the prompt).
|
|
22
|
+
export function agentPrompt(prompt, store, { tool = 'wisp_session', self } = {}) {
|
|
23
|
+
const found = mentionsIn(prompt).filter((m) => m.id !== self);
|
|
24
|
+
if (!found.length) return textMentions(prompt);
|
|
25
|
+
const line = (m) => {
|
|
26
|
+
const s = store.get(m.id);
|
|
27
|
+
if (!s) return `- \`${m.id}\` (${m.title}): not on this computer; it may have been deleted or run on another machine.`;
|
|
28
|
+
return `- \`${s.id}\` **${s.title}**: ${s.state}, ${s.agent}, ${s.turns.length} turn${s.turns.length === 1 ? '' : 's'}, in \`${s.worktree?.path || s.cwd}\``;
|
|
29
|
+
};
|
|
30
|
+
const note = tool
|
|
31
|
+
? `The user mentions other wisp tasks. Read one with \`wisp_session\` (${tool}) before relying on it; \`wisp_sessions\` lists the rest.\n${found.map(line).join('\n')}`
|
|
32
|
+
: `The user mentions other wisp tasks. Their latest reports:\n\n${found
|
|
33
|
+
.map((m) => {
|
|
34
|
+
const s = store.get(m.id);
|
|
35
|
+
return s ? `### ${s.title} (\`${s.id}\`)\n\n${truncate(sessionToMarkdown(s), INLINE_MAX)}` : line(m);
|
|
36
|
+
})
|
|
37
|
+
.join('\n\n')}`;
|
|
38
|
+
return `${textMentions(prompt)}\n\n<wisp-tasks>\n${note}\n</wisp-tasks>`;
|
|
39
|
+
}
|
package/server/openapi.js
CHANGED
|
@@ -90,7 +90,19 @@ export function openapi({ version, port }) {
|
|
|
90
90
|
},
|
|
91
91
|
'/projects': {
|
|
92
92
|
get: { summary: 'List projects', responses: { 200: json({ type: 'array', items: ref('Project') }) } },
|
|
93
|
-
post: {
|
|
93
|
+
post: {
|
|
94
|
+
summary: 'Add a folder as a project, or clone a repo URL and add the clone',
|
|
95
|
+
requestBody: body({
|
|
96
|
+
type: 'object',
|
|
97
|
+
properties: {
|
|
98
|
+
path: { type: 'string', description: 'A folder, or a repo URL (https://github.com/o/r, git@host:o/r.git, o/r)' },
|
|
99
|
+
repo: { type: 'string', description: 'A repo URL to clone into defaults.cloneDir (default ~/code), then add' },
|
|
100
|
+
name: { type: 'string' },
|
|
101
|
+
machine: { type: 'string', description: 'Add (or clone) it on this machine' },
|
|
102
|
+
},
|
|
103
|
+
}),
|
|
104
|
+
responses: { 201: json(ref('Project')), 400: err[400] },
|
|
105
|
+
},
|
|
94
106
|
},
|
|
95
107
|
'/projects/{pid}': {
|
|
96
108
|
parameters: [{ name: 'pid', in: 'path', required: true, schema: { type: 'string' } }],
|
|
@@ -132,6 +144,7 @@ export function openapi({ version, port }) {
|
|
|
132
144
|
},
|
|
133
145
|
},
|
|
134
146
|
'/sessions/{id}/pr': { parameters: [id], post: { summary: 'Refresh the PR status (checks, review, state) now; it is also polled every minute', responses: { 200: json(ref('PullRequest')), 404: err[404] } } },
|
|
147
|
+
'/sessions/{id}/fix-conflicts': { parameters: [id], post: { summary: 'Retry the automatic merge-conflict fix after it gave up: a follow-up merges the base in, then wisp pushes to the PR', responses: { 200: json({ type: 'object' }), 400: err[400] } } },
|
|
135
148
|
'/sessions/{id}/merge': {
|
|
136
149
|
parameters: [id],
|
|
137
150
|
post: { summary: 'Mark the PR ready if draft, squash-merge it with --match-head-commit, fast-forward the local base branch, remove the worktree', responses: { 200: json({ type: 'object', properties: { pr: ref('PullRequest'), sync: { type: 'object', properties: { ok: { type: 'boolean' }, text: { type: 'string' } } } } }), 400: err[400] } },
|
|
@@ -226,7 +239,8 @@ export function openapi({ version, port }) {
|
|
|
226
239
|
responses: { 200: json({ type: 'object', properties: { answered: { type: 'boolean' }, queued: { type: 'boolean' }, interrupted: { type: 'boolean' }, ahead: { type: 'integer' } } }), 400: err[400] },
|
|
227
240
|
},
|
|
228
241
|
},
|
|
229
|
-
'/sessions/{id}/answer': { parameters: [id], post: { summary: 'Answer the pending ask_user question', requestBody: body({ type: 'object', required: ['text'], properties: { text: { type: 'string' } } }), responses: { 200: ok } } },
|
|
242
|
+
'/sessions/{id}/answer': { parameters: [id], post: { summary: 'Answer the pending ask_user question (or approval: Allow, Always allow in this task, Deny, or what to do instead)', requestBody: body({ type: 'object', required: ['text'], properties: { text: { type: 'string' } } }), responses: { 200: ok } } },
|
|
243
|
+
'/sessions/{id}/approve': { parameters: [id], post: { summary: 'Allow and retry (or deny) what a finished turn’s permissions blocked', requestBody: body({ type: 'object', properties: { turnId: { type: 'string' }, allow: { type: 'boolean', description: 'Default true' } } }), responses: { 200: ok } } },
|
|
230
244
|
'/sessions/{id}/revert': {
|
|
231
245
|
parameters: [id],
|
|
232
246
|
post: {
|
|
@@ -290,9 +304,10 @@ export function openapi({ version, port }) {
|
|
|
290
304
|
properties: {
|
|
291
305
|
prompt: { type: 'string' },
|
|
292
306
|
cwd: { type: 'string', description: 'Project folder (absolute; ~ allowed). Default: the server’s cwd' },
|
|
307
|
+
scratch: { type: 'boolean', description: 'No project folder: run in an empty scratch directory (no worktree). Overrides cwd' },
|
|
293
308
|
agent: { enum: ['auto', 'claude', 'codex', 'opencode', 'cursor', 'antigravity', 'grok'], default: 'auto', description: 'auto: wisp picks the harness and model from what worked before; model is then the goal (cheap, balanced, best)' },
|
|
294
309
|
important: { type: 'boolean', description: 'With agent auto: no exploring, take the pick with the best record' },
|
|
295
|
-
mode: { enum: ['plan', 'edits', 'auto'], default: '
|
|
310
|
+
mode: { enum: ['plan', 'edits', 'auto'], default: 'auto' },
|
|
296
311
|
model: { type: 'string' },
|
|
297
312
|
effort: { type: 'string', description: 'Thinking level: low, medium, high, xhigh, max (per harness)' },
|
|
298
313
|
attachments: { type: 'array', items: { type: 'string' }, description: 'Upload ids' },
|
|
@@ -311,6 +326,7 @@ export function openapi({ version, port }) {
|
|
|
311
326
|
draft: { type: 'boolean' },
|
|
312
327
|
review: { type: 'string', description: 'APPROVED | CHANGES_REQUESTED | REVIEW_REQUIRED | ""' },
|
|
313
328
|
checks: { type: 'object', properties: { pass: { type: 'integer' }, fail: { type: 'integer' }, pending: { type: 'integer' } } },
|
|
329
|
+
conflict: { type: 'boolean', description: 'GitHub cannot merge it into its base: merge conflicts' },
|
|
314
330
|
head: { type: 'string' },
|
|
315
331
|
base: { type: 'string' },
|
|
316
332
|
},
|
|
@@ -392,6 +408,8 @@ export function openapi({ version, port }) {
|
|
|
392
408
|
updatedAt: { type: 'integer' },
|
|
393
409
|
branch: { type: 'string', description: 'Worktree branch, if it has one' },
|
|
394
410
|
pr: { type: ['object', 'null'], description: 'Its pull request, once shipped' },
|
|
411
|
+
shipping: { type: ['object', 'null'], description: "{ step: start | commit | push | pr, update } while Ship it runs" },
|
|
412
|
+
conflictFix: { type: ['object', 'null'], description: "{ step: fixing | pushing | done | failed, why } while wisp fixes the PR's merge conflicts on its own (defaults.fixConflicts: 'off' turns it off)" },
|
|
395
413
|
groupId: { type: ['string', 'null'], description: 'Shared by sessions launched together with compare' },
|
|
396
414
|
origin: { type: ['object', 'null'], description: '{ kind: recipe | trigger, id, name } when not launched by hand' },
|
|
397
415
|
},
|
package/server/protocol.js
CHANGED
|
@@ -69,6 +69,9 @@ diff, the test run, the command output — over prose. Use file paths relative t
|
|
|
69
69
|
A screenshot or generated image on disk.
|
|
70
70
|
- {"type":"question","question":"…","options":[{"label":"Short choice","reply":"full message sent back to you"}]}
|
|
71
71
|
When you need a decision. The user's click resumes you with the reply.
|
|
72
|
+
- {"type":"blocked","items":[{"tool":"shell","detail":"npm publish","why":"needs network access"}]}
|
|
73
|
+
Actions your permissions or sandbox refused (a denied tool call, a command that failed for lack of
|
|
74
|
+
network or write access). wisp shows them with an Allow & retry button. Not for ordinary failures.
|
|
72
75
|
- {"type":"actions","title":"…","items":[{"label":"Run tests","command":"npm test"},{"label":"Follow up","reply":"…"}]}
|
|
73
76
|
One-click shell commands (executed in the project root) or follow-up actions the user may want to trigger.
|
|
74
77
|
- {"type":"next","items":["Suggested follow-up the user might send", "…"]}
|
|
@@ -89,7 +92,11 @@ Strings must be valid JSON (escape newlines as \\n and quotes as \\"). Example:
|
|
|
89
92
|
|
|
90
93
|
const ASK_TOOL = (tool) => `- If a decision would change the rest of the work (ambiguous requirement, destructive or risky step,
|
|
91
94
|
two very different approaches), call the \`ask_user\` tool (${tool}). It pauses you until
|
|
92
|
-
the user answers in wisp — you keep all your context. Ask one crisp question with 2–4 options
|
|
95
|
+
the user answers in wisp — you keep all your context. Ask one crisp question with 2–4 options.
|
|
96
|
+
- If the user asks about their past wisp tasks (which tools or harnesses they use, cost, success rate),
|
|
97
|
+
call \`wisp_stats\` (${tool.replace(/ask_user$/, 'wisp_stats')}) instead of digging through files.
|
|
98
|
+
- If the prompt mentions another wisp task ("@Title (wisp task <id>)") or the user asks what another task
|
|
99
|
+
did, read it with \`wisp_session\` (${tool.replace(/ask_user$/, 'wisp_session')}); \`wisp_sessions\` lists them.`;
|
|
93
100
|
const ASK_BLOCK = `- If a decision would change the rest of the work (ambiguous requirement, destructive or risky step,
|
|
94
101
|
two very different approaches), stop before acting on it and end with a "question" block (2–4 options).
|
|
95
102
|
The user's answer comes back as a follow-up in this same session.`;
|
package/server/render.js
CHANGED
|
@@ -80,6 +80,8 @@ const R = {
|
|
|
80
80
|
callout: (b) => `> **${b.tone === 'danger' ? '⛔' : b.tone === 'warn' ? '⚠️' : b.tone === 'success' ? '✅' : 'ℹ️'} ${str(b.title)}**\n> ${str(b.text).replace(/\n/g, '\n> ')}`,
|
|
81
81
|
image: (b) => `🖼️ \`${str(b.path || b.src)}\`${b.caption ? ` — ${b.caption}` : ''}`,
|
|
82
82
|
question: (b) => `**❓ ${str(b.question)}**\n${list(b.options).map((o, i) => `${i + 1}. ${str(o.label || o)}`).join('\n')}`,
|
|
83
|
+
blocked: (b, turn) =>
|
|
84
|
+
`**🔒 Blocked by permissions**${turn?.approval ? ` _(${turn.approval})_` : ''}\n${list(b.items).map((i) => `- ${str(i.tool)}${i.detail ? `: \`${str(i.detail)}\`` : ''}${i.why ? ` — ${i.why}` : ''}`).join('\n')}`,
|
|
83
85
|
actions: (b) =>
|
|
84
86
|
`${b.title ? `**${b.title}**\n` : '**Actions**\n'}${list(b.items)
|
|
85
87
|
.map((a) => (typeof a === 'string' ? `- ${a}` : `- **${str(a.label || a.command)}**${a.command ? ` — \`$ ${str(a.command)}\`` : a.reply ? ` — _${str(a.reply)}_` : ''}${a.detail ? ` (${str(a.detail)})` : ''}`))
|
|
@@ -109,7 +111,7 @@ export function sessionToMarkdown(session) {
|
|
|
109
111
|
if (turn?.status === 'running') out.push(`_Working… (${turn.activity?.findLast?.((a) => a.kind !== 'text')?.label || 'starting up'})_`);
|
|
110
112
|
else if (turn?.presentation) out.push(blocksToMarkdown(turn.presentation.blocks, turn));
|
|
111
113
|
if (session.ask) {
|
|
112
|
-
out.push(
|
|
114
|
+
out.push(`**${session.ask.permission ? '🔒 The agent needs your approval' : '❓ The agent is waiting for your answer'}:** ${session.ask.question}${session.ask.context ? `\n\n${session.ask.context}` : ''}`);
|
|
113
115
|
if (session.ask.options?.length) out.push(session.ask.options.map((o, i) => `${i + 1}. ${o}`).join('\n'));
|
|
114
116
|
}
|
|
115
117
|
// Worktree sessions: say where the changes are, since they aren't in the caller's checkout.
|
|
@@ -1,8 +1,14 @@
|
|
|
1
|
-
// Bridge for the MCP servers running inside an agent process (ask_user, delegate
|
|
1
|
+
// Bridge for the MCP servers running inside an agent process (ask_user, approve, delegate, wisp_stats,
|
|
2
|
+
// wisp_sessions, wisp_session). Every call
|
|
2
3
|
// carries the per-run token wisp handed the agent; nothing else may use these.
|
|
3
4
|
|
|
4
5
|
import { adapters } from '../adapters/index.js';
|
|
6
|
+
import { plainMentions } from '../mentions.js';
|
|
7
|
+
import { blocksToMarkdown, sessionToMarkdown } from '../render.js';
|
|
8
|
+
import { summarize } from '../runner.js';
|
|
5
9
|
import { delegationReport } from '../subagents.js';
|
|
10
|
+
import { toolStats, toolStatsText } from '../usage.js';
|
|
11
|
+
import { clip } from '../util.js';
|
|
6
12
|
|
|
7
13
|
export function register(r, app) {
|
|
8
14
|
const { store, runner, telemetry } = app;
|
|
@@ -25,6 +31,22 @@ export function register(r, app) {
|
|
|
25
31
|
);
|
|
26
32
|
r.route('GET', '/internal/ask/:id', guarded((c) => c.send(200, runner.pollAnswer(c.params.id))), opts);
|
|
27
33
|
|
|
34
|
+
// A live approval (the harness's permission prompt): { decision } at once, or an { id } to poll
|
|
35
|
+
// like ask_user, whose answer carries the decision.
|
|
36
|
+
r.route(
|
|
37
|
+
'POST',
|
|
38
|
+
'/internal/approve',
|
|
39
|
+
guarded(async (c) => {
|
|
40
|
+
const body = await c.body();
|
|
41
|
+
const s = store.get(body.session);
|
|
42
|
+
if (!s) return c.send(404, { error: 'no such session' });
|
|
43
|
+
const out = runner.requestApproval(s, body);
|
|
44
|
+
if (out.id) telemetry.track('approval', { agent: s.agent });
|
|
45
|
+
c.send(200, out);
|
|
46
|
+
}),
|
|
47
|
+
opts,
|
|
48
|
+
);
|
|
49
|
+
|
|
28
50
|
// delegate: POST starts tasks on the lead's subagents; GET reports on them (done once all finished).
|
|
29
51
|
r.route(
|
|
30
52
|
'POST',
|
|
@@ -53,4 +75,102 @@ export function register(r, app) {
|
|
|
53
75
|
}),
|
|
54
76
|
opts,
|
|
55
77
|
);
|
|
78
|
+
|
|
79
|
+
// wisp_stats: read-only tool counts, spend and success across sessions. project=current is the
|
|
80
|
+
// asking session's project.
|
|
81
|
+
r.route(
|
|
82
|
+
'GET',
|
|
83
|
+
'/internal/stats/:id',
|
|
84
|
+
guarded((c) => {
|
|
85
|
+
const s = lead(c);
|
|
86
|
+
if (!s) return c.send(404, { error: 'no such session' });
|
|
87
|
+
const projectFor = (x) => store.projectFor(x.cwd);
|
|
88
|
+
let project = c.query.get('project') || '';
|
|
89
|
+
if (project === 'current') project = projectFor(s)?.id || String(s.cwd || '').split(/[\\/]/).pop();
|
|
90
|
+
const stats = toolStats(store.all(), { days: Number(c.query.get('days')) || 30, project: projectFor, filter: { agent: c.query.get('agent') || '', project } });
|
|
91
|
+
telemetry.track('wisp_stats', { agent: s.agent });
|
|
92
|
+
c.send(200, { stats, text: toolStatsText(stats) });
|
|
93
|
+
}),
|
|
94
|
+
opts,
|
|
95
|
+
);
|
|
96
|
+
// wisp_sessions: the user's other tasks (not the asker, its subagents or the tour), newest first.
|
|
97
|
+
r.route(
|
|
98
|
+
'GET',
|
|
99
|
+
'/internal/sessions/:id',
|
|
100
|
+
guarded((c) => {
|
|
101
|
+
const s = lead(c);
|
|
102
|
+
if (!s) return c.send(404, { error: 'no such session' });
|
|
103
|
+
const q = Object.fromEntries(c.query);
|
|
104
|
+
let project = q.project || '';
|
|
105
|
+
if (project === 'current') project = store.projectFor(s.cwd)?.id || String(s.cwd || '').split(/[\\/]/).pop();
|
|
106
|
+
const states = q.state ? q.state.split(',').map((x) => x.trim()) : null;
|
|
107
|
+
const words = String(q.query || '').toLowerCase().split(/\s+/).filter(Boolean);
|
|
108
|
+
const all = store
|
|
109
|
+
.all()
|
|
110
|
+
.filter((x) => x.id !== s.id && !x.parentId && x.agent !== 'wisp' && x.id !== s.parentId)
|
|
111
|
+
.map((x) => summarize(x, store))
|
|
112
|
+
.filter((x) => (q.archived === 'true' || !x.archived) && (!states || states.includes(x.state)))
|
|
113
|
+
.filter((x) => !project || x.projectId === project || x.project === project)
|
|
114
|
+
.filter((x) => words.every((w) => `${x.title} ${x.preview}`.toLowerCase().includes(w)))
|
|
115
|
+
.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
116
|
+
const list = all.slice(0, Math.min(Math.max(Number(q.limit) || 20, 1), 100));
|
|
117
|
+
telemetry.track('wisp_sessions', { agent: s.agent });
|
|
118
|
+
const text = list.length
|
|
119
|
+
? `${all.length} task${all.length === 1 ? '' : 's'}${all.length > list.length ? `, the newest ${list.length}` : ''}. Read one with wisp_session.\n\n${list.map(sessionLine).join('\n')}`
|
|
120
|
+
: 'No tasks match.';
|
|
121
|
+
c.send(200, { text });
|
|
122
|
+
}),
|
|
123
|
+
opts,
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
// wisp_session: one task's requests and a turn's report (?of=<id>&turn=<1-based>).
|
|
127
|
+
r.route(
|
|
128
|
+
'GET',
|
|
129
|
+
'/internal/session/:id',
|
|
130
|
+
guarded((c) => {
|
|
131
|
+
const s = lead(c);
|
|
132
|
+
if (!s) return c.send(404, { error: 'no such session' });
|
|
133
|
+
const id = String(c.query.get('of') || '').trim().replace(/^wisp:/, '');
|
|
134
|
+
const x = store.get(id);
|
|
135
|
+
telemetry.track('wisp_session', { agent: s.agent });
|
|
136
|
+
if (!x) return c.send(200, { text: `No wisp task \`${id}\` on this computer. It may have been deleted, or it ran on another machine. List tasks with wisp_sessions.` });
|
|
137
|
+
c.send(200, { text: sessionText(x, store, c.query.get('turn')) });
|
|
138
|
+
}),
|
|
139
|
+
opts,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const age = (t) => {
|
|
144
|
+
const m = Math.round((Date.now() - t) / 60_000);
|
|
145
|
+
return m < 1 ? 'just now' : m < 60 ? `${m}m ago` : m < 48 * 60 ? `${Math.round(m / 60)}h ago` : `${Math.round(m / 1440)}d ago`;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const sessionLine = (x) =>
|
|
149
|
+
`- \`${x.id}\` **${x.title}** — ${x.archived ? 'archived, ' : ''}${x.state}, ${x.agent}${x.model ? ` (${x.model})` : ''}, ${x.project}, ${age(x.updatedAt)}${x.state === 'running' && x.live ? `: ${x.live}` : x.preview ? `: ${clip(x.preview, 160)}` : ''}`;
|
|
150
|
+
|
|
151
|
+
export function sessionText(x, store, turnArg) {
|
|
152
|
+
const sum = summarize(x, store);
|
|
153
|
+
const n = Number(turnArg);
|
|
154
|
+
const turn = turnArg === null || turnArg === undefined || turnArg === '' || !n ? x.turns.at(-1) : x.turns[n - 1];
|
|
155
|
+
const head = [
|
|
156
|
+
`# ${x.title}`,
|
|
157
|
+
`Task \`${x.id}\` · ${sum.archived ? 'archived · ' : ''}${x.state} · ${x.agent}${x.model ? ` (${x.model})` : ''} · project ${sum.project} · updated ${age(x.updatedAt)}`,
|
|
158
|
+
`Folder: \`${x.worktree?.path || x.cwd}\`${x.worktree ? ` (worktree, branch \`${x.worktree.branch}\`)` : ''}${x.pr ? ` · PR #${x.pr.number} (${String(x.pr.state).toLowerCase()}): ${x.pr.url}` : ''}`,
|
|
159
|
+
];
|
|
160
|
+
const asks = x.turns.map((t, i) => `${i + 1}. ${clip(plainMentions(t.prompt).replace(/\s+/g, ' '), 300)}${t.status === 'running' ? ' _(running)_' : t.status === 'error' ? ' _(failed)_' : t.status === 'stopped' ? ' _(stopped)_' : ''}`);
|
|
161
|
+
if (!turn) return [...head, '', `Turn ${turnArg} doesn't exist; this task has ${x.turns.length}.`, '', '## Requests', ...asks].join('\n');
|
|
162
|
+
const i = x.turns.indexOf(turn);
|
|
163
|
+
const report = turn === x.turns.at(-1) ? sessionToMarkdown(x) : blocksToMarkdown(turn.presentation?.blocks, turn);
|
|
164
|
+
const files = (turn.changes || []).filter((ch) => !ch.reverted).map((ch) => `\`${ch.file}\``);
|
|
165
|
+
return [
|
|
166
|
+
...head,
|
|
167
|
+
'',
|
|
168
|
+
`## Requests (${x.turns.length})`,
|
|
169
|
+
...asks,
|
|
170
|
+
'',
|
|
171
|
+
`## Report for turn ${i + 1}${x.turns.length > 1 ? ` of ${x.turns.length} (pass turn to read another)` : ''}`,
|
|
172
|
+
'',
|
|
173
|
+
report || '_No report yet._',
|
|
174
|
+
...(files.length ? ['', `Files edited in this turn: ${files.join(', ')}`] : []),
|
|
175
|
+
].join('\n');
|
|
56
176
|
}
|
package/server/routes/meta.js
CHANGED
|
@@ -34,7 +34,7 @@ export function register(r, app) {
|
|
|
34
34
|
|
|
35
35
|
r.route('GET', '/openapi.json', (c) => c.send(200, openapi({ version: VERSION, port })), { doc: false });
|
|
36
36
|
|
|
37
|
-
r.route('GET', '/agents', async (c) => c.send(200, await app.auto.detect({ fresh:
|
|
37
|
+
r.route('GET', '/agents', async (c) => c.send(200, await (c.query.has('fresh') ? app.auto.detect({ fresh: true }) : app.auto.listed())));
|
|
38
38
|
// Re-check: detect harnesses and models again, here and on every online machine.
|
|
39
39
|
r.route('POST', '/agents/recheck', async (c) => {
|
|
40
40
|
const [agents] = await Promise.all([app.auto.detect({ fresh: true }), machines.recheckAgents()]);
|
|
@@ -206,6 +206,18 @@ export function register(r, app) {
|
|
|
206
206
|
}),
|
|
207
207
|
);
|
|
208
208
|
|
|
209
|
+
// The card under a turn whose permissions blocked something: allow retries it, deny closes it.
|
|
210
|
+
r.route(
|
|
211
|
+
'POST',
|
|
212
|
+
'/sessions/:id/approve',
|
|
213
|
+
one(async (c, s) => {
|
|
214
|
+
const { turnId, allow } = await c.body();
|
|
215
|
+
notTour(s, 'approve');
|
|
216
|
+
await ensureWorktree(s.worktree);
|
|
217
|
+
c.send(200, runner.approveBlocked(s, { turnId, allow: allow !== false }));
|
|
218
|
+
}),
|
|
219
|
+
);
|
|
220
|
+
|
|
209
221
|
// Revert (or re-apply) a single file's edits from a turn, or roll back (or re-apply) the
|
|
210
222
|
// project's working tree to the checkpoint before a turn. Both note it on the turn and tell
|
|
211
223
|
// the cards; Ship it may have something new (or nothing left) to ship.
|
|
@@ -9,16 +9,27 @@ import { normalizePreset } from '../subagents.js';
|
|
|
9
9
|
import { envDisabled, feedbackAvailable } from '../telemetry.js';
|
|
10
10
|
import { TERMINALS } from '../terminal.js';
|
|
11
11
|
import { normalizeWebhooks } from '../services/webhooks.js';
|
|
12
|
+
import { cloneRepo, gh, git, parseRepo } from '../git.js';
|
|
12
13
|
import { expand } from '../util.js';
|
|
13
14
|
|
|
14
15
|
// Where people keep their repos, for one-click "add project".
|
|
15
16
|
const REPO_ROOTS = ['code', 'Code', 'dev', 'Developer', 'projects', 'Projects', 'src', 'repos', 'work', 'git', 'GitHub', 'Documents', 'Downloads', 'Desktop'];
|
|
16
17
|
|
|
18
|
+
// Where a pasted repo URL is cloned: defaults.cloneDir, else the first usual repo folder that
|
|
19
|
+
// exists (not Documents/Downloads/Desktop), else ~/code.
|
|
20
|
+
export function cloneRoot(config) {
|
|
21
|
+
if (config.defaults?.cloneDir) return expand(config.defaults.cloneDir);
|
|
22
|
+
const dir = REPO_ROOTS.slice(0, -3)
|
|
23
|
+
.map((r) => path.join(os.homedir(), r))
|
|
24
|
+
.find((d) => fs.existsSync(d) && fs.statSync(d).isDirectory());
|
|
25
|
+
return dir || path.join(os.homedir(), 'code');
|
|
26
|
+
}
|
|
27
|
+
|
|
17
28
|
export function register(r, app) {
|
|
18
29
|
const { store, telemetry, prefs, retro, remote, machines } = app;
|
|
19
30
|
|
|
20
31
|
r.route('GET', '/config', (c) =>
|
|
21
|
-
c.send(200, { ...store.config, remote: undefined, home: os.homedir(), cwd: process.cwd(), terminals: TERMINALS, telemetry: { available: !envDisabled(), enabled: telemetry.enabled }, feedback: feedbackAvailable() }),
|
|
32
|
+
c.send(200, { ...store.config, remote: undefined, home: os.homedir(), cwd: process.cwd(), cloneDir: cloneRoot(store.config), terminals: TERMINALS, telemetry: { available: !envDisabled(), enabled: telemetry.enabled }, feedback: feedbackAvailable() }),
|
|
22
33
|
);
|
|
23
34
|
r.route('PATCH', '/config', async (c) => {
|
|
24
35
|
const body = await c.body();
|
|
@@ -91,18 +102,54 @@ export function register(r, app) {
|
|
|
91
102
|
r.route('POST', '/projects', async (c) => {
|
|
92
103
|
const body = await c.body();
|
|
93
104
|
if (body.machine) {
|
|
94
|
-
const p = await machines.addProject(String(body.machine), String(body.path || '').trim(), body.name);
|
|
105
|
+
const p = await machines.addProject(String(body.machine), String(body.repo || body.path || '').trim(), body.name);
|
|
95
106
|
app.broadcast('config', {});
|
|
96
107
|
return c.send(201, p);
|
|
97
108
|
}
|
|
98
|
-
|
|
109
|
+
// A repo URL (in `repo`, or pasted into `path`): clone it, then add the clone.
|
|
110
|
+
const typed = String(body.repo || body.path || '').trim();
|
|
111
|
+
const local = !body.repo && (/^[~./\\]|^[a-z]:/i.test(typed) || fs.existsSync(expand(typed)));
|
|
112
|
+
const repo = !local && parseRepo(typed, { shorthand: true });
|
|
113
|
+
if (body.repo && !repo) return c.send(400, { error: `Not a repo URL: ${typed}` });
|
|
114
|
+
let dir = expand(typed);
|
|
115
|
+
let cloned = null;
|
|
116
|
+
if (repo) {
|
|
117
|
+
const out = await cloneRepo(repo, cloneRoot(store.config));
|
|
118
|
+
dir = out.dir;
|
|
119
|
+
cloned = { url: repo.url, existed: out.existed };
|
|
120
|
+
}
|
|
99
121
|
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) return c.send(400, { error: `Not a folder: ${dir}` });
|
|
100
122
|
const p = store.addProject(dir, { name: body.name });
|
|
101
123
|
app.broadcast('config', {});
|
|
102
|
-
c.send(201, p);
|
|
124
|
+
c.send(201, cloned ? { ...p, cloned } : p);
|
|
103
125
|
});
|
|
104
126
|
// Find git repos in the usual places so adding projects is one click.
|
|
105
127
|
r.route('GET', '/projects/discover', (c) => c.send(200, discoverRepos(new Set(store.config.projects.map((p) => p.path)))), { doc: false });
|
|
128
|
+
// The user's GitHub repos (gh's login), newest first, minus ones already added: one click clones.
|
|
129
|
+
// Empty when gh isn't installed or signed in.
|
|
130
|
+
r.route(
|
|
131
|
+
'GET',
|
|
132
|
+
'/projects/github',
|
|
133
|
+
async (c) => {
|
|
134
|
+
const list = await gh(os.homedir(), ['repo', 'list', '--limit', '40', '--json', 'nameWithOwner,description,isPrivate,isFork,pushedAt,url'], { timeout: 20_000 })
|
|
135
|
+
.then((out) => JSON.parse(out || '[]'))
|
|
136
|
+
.catch(() => null);
|
|
137
|
+
if (!list) return c.send(200, []);
|
|
138
|
+
const origins = await Promise.all(
|
|
139
|
+
store.config.projects.filter((p) => !p.machine).map((p) => git(p.path, ['remote', 'get-url', 'origin'], { timeout: 5000 }).catch(() => '')),
|
|
140
|
+
);
|
|
141
|
+
const have = new Set(origins.map((o) => parseRepo(o)?.gh?.toLowerCase()).filter(Boolean));
|
|
142
|
+
c.send(
|
|
143
|
+
200,
|
|
144
|
+
list
|
|
145
|
+
.filter((x) => !have.has(x.nameWithOwner.toLowerCase()))
|
|
146
|
+
.sort((a, b) => String(b.pushedAt).localeCompare(String(a.pushedAt)))
|
|
147
|
+
.slice(0, 24)
|
|
148
|
+
.map((x) => ({ repo: x.nameWithOwner, url: x.url, description: x.description || '', private: !!x.isPrivate, fork: !!x.isFork, pushedAt: x.pushedAt })),
|
|
149
|
+
);
|
|
150
|
+
},
|
|
151
|
+
{ doc: false },
|
|
152
|
+
);
|
|
106
153
|
const project = (c) => store.config.projects.find((x) => x.id === c.params.id);
|
|
107
154
|
r.route('PATCH', '/projects/:id', async (c) => {
|
|
108
155
|
const p = project(c);
|