wisp-agents 0.15.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/CHANGELOG.md +55 -1
  2. package/README.md +29 -13
  3. package/bin/wisp.js +3 -2
  4. package/package.json +3 -3
  5. package/server/adapters/antigravity.js +7 -3
  6. package/server/adapters/claude.js +79 -55
  7. package/server/adapters/codex.js +5 -2
  8. package/server/adapters/common.js +14 -34
  9. package/server/adapters/cursor.js +4 -2
  10. package/server/adapters/grok.js +17 -2
  11. package/server/adapters/index.js +25 -2
  12. package/server/adapters/opencode.js +78 -12
  13. package/server/checkpoint.js +12 -68
  14. package/server/client.js +6 -4
  15. package/server/git.js +9 -4
  16. package/server/handoff.js +71 -0
  17. package/server/http/respond.js +59 -0
  18. package/server/http/router.js +64 -0
  19. package/server/http/static.js +36 -0
  20. package/server/index.js +138 -1216
  21. package/server/machines/bootstrap.js +56 -0
  22. package/server/machines/hosts.js +127 -0
  23. package/server/machines.js +7 -179
  24. package/server/mcp-ask.js +113 -8
  25. package/server/mcp-wisp.js +17 -5
  26. package/server/mentions.js +39 -0
  27. package/server/openapi.js +36 -4
  28. package/server/paths.js +32 -0
  29. package/server/preferences.js +2 -6
  30. package/server/proc.js +54 -0
  31. package/server/protocol.js +8 -1
  32. package/server/recipes.js +2 -4
  33. package/server/remote.js +14 -14
  34. package/server/render.js +3 -1
  35. package/server/router.js +7 -3
  36. package/server/routes/auto.js +29 -0
  37. package/server/routes/automations.js +92 -0
  38. package/server/routes/internal.js +176 -0
  39. package/server/routes/machines.js +43 -0
  40. package/server/routes/meta.js +76 -0
  41. package/server/routes/proxy.js +47 -0
  42. package/server/routes/sessions.js +351 -0
  43. package/server/routes/settings.js +144 -0
  44. package/server/routes/ship.js +61 -0
  45. package/server/routes/uploads.js +19 -0
  46. package/server/runner.js +216 -49
  47. package/server/services/auto.js +67 -0
  48. package/server/services/feedback.js +53 -0
  49. package/server/services/install.js +69 -0
  50. package/server/services/launch.js +127 -0
  51. package/server/services/phone.js +65 -0
  52. package/server/services/ship.js +82 -0
  53. package/server/services/telemetry.js +34 -0
  54. package/server/services/uploads.js +59 -0
  55. package/server/services/webhooks.js +28 -0
  56. package/server/store.js +13 -5
  57. package/server/subagents.js +5 -4
  58. package/server/telemetry.js +1 -1
  59. package/server/terminal.js +2 -1
  60. package/server/triggers.js +5 -7
  61. package/server/update.js +4 -3
  62. package/server/usage.js +94 -0
  63. package/server/util.js +42 -0
  64. package/server/welcome.js +3 -3
  65. package/web/css/auto.css +29 -0
  66. package/web/css/automations.css +71 -0
  67. package/web/css/base.css +83 -0
  68. package/web/css/blocks.css +461 -0
  69. package/web/css/composer.css +84 -0
  70. package/web/css/feed.css +235 -0
  71. package/web/css/launcher.css +204 -0
  72. package/web/css/layout.css +61 -0
  73. package/web/css/machines.css +125 -0
  74. package/web/css/modals.css +176 -0
  75. package/web/css/palette.css +79 -0
  76. package/web/css/responsive.css +35 -0
  77. package/web/css/ship.css +95 -0
  78. package/web/css/subagents.css +130 -0
  79. package/web/css/tokens.css +115 -0
  80. package/web/index.html +17 -1
  81. package/web/js/app.js +28 -5237
  82. package/web/js/blocks.js +46 -0
  83. package/web/js/core/api.js +25 -0
  84. package/web/js/core/bus.js +28 -0
  85. package/web/js/core/dom.js +7 -0
  86. package/web/js/core/format.js +27 -0
  87. package/web/js/core/state.js +43 -0
  88. package/web/js/features/actions.js +344 -0
  89. package/web/js/features/attachments.js +115 -0
  90. package/web/js/features/auto.js +54 -0
  91. package/web/js/features/automations.js +305 -0
  92. package/web/js/features/commands.js +123 -0
  93. package/web/js/features/compare.js +105 -0
  94. package/web/js/features/composer.js +315 -0
  95. package/web/js/features/feed.js +400 -0
  96. package/web/js/features/feedback.js +127 -0
  97. package/web/js/features/keys.js +83 -0
  98. package/web/js/features/launcher.js +561 -0
  99. package/web/js/features/live.js +91 -0
  100. package/web/js/features/machines.js +313 -0
  101. package/web/js/features/memory.js +154 -0
  102. package/web/js/features/mentions.js +125 -0
  103. package/web/js/features/notify.js +36 -0
  104. package/web/js/features/phone.js +124 -0
  105. package/web/js/features/places.js +38 -0
  106. package/web/js/features/projects.js +284 -0
  107. package/web/js/features/runner-picker.js +233 -0
  108. package/web/js/features/runners.js +163 -0
  109. package/web/js/features/scroll.js +75 -0
  110. package/web/js/features/settings.js +390 -0
  111. package/web/js/features/setup.js +94 -0
  112. package/web/js/features/ship.js +122 -0
  113. package/web/js/features/team.js +386 -0
  114. package/web/js/features/turns.js +432 -0
  115. package/web/js/features/update.js +48 -0
  116. package/web/js/features/usage.js +75 -0
  117. package/web/js/mention-text.js +59 -0
  118. package/web/js/ui/kbd.js +46 -0
  119. package/web/js/ui/overlay.js +38 -0
  120. package/web/js/ui/popover.js +52 -0
  121. package/web/js/ui/toast.js +23 -0
  122. package/web/css/app.css +0 -1910
@@ -9,7 +9,7 @@
9
9
  import fs from 'node:fs';
10
10
  import os from 'node:os';
11
11
  import path from 'node:path';
12
- import { ASK_TIMEOUT_MS, attachmentNote, clip, detectVersion, fileTracker, flatten, quietOutput, rel, spawnJsonl } from './common.js';
12
+ import { ASK_TIMEOUT_MS, attachmentNote, clip, detectVersion, exitText, fileTracker, flatten, quietOutput, rel, spawnJsonl } from './common.js';
13
13
 
14
14
  const BIN = process.env.WISP_GROK_BIN || 'grok';
15
15
 
@@ -19,6 +19,9 @@ const PERMISSION_MODES = {
19
19
  auto: 'bypassPermissions',
20
20
  };
21
21
  const EFFORT_ORDER = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'];
22
+ // A headless run refuses what would need approval and names it on stderr, once per call:
23
+ // "permission required: Edit calc.py was not run, as a headless run cannot ask for approval".
24
+ const REFUSED = /permission required: (\S+) ?(.*?) was not run\b/g;
22
25
 
23
26
  export default {
24
27
  id: 'grok',
@@ -75,6 +78,7 @@ export default {
75
78
  let session = null;
76
79
  let sawResult = false;
77
80
  let error = null;
81
+ let carry = ''; // a stderr line still being written
78
82
 
79
83
  const handle = spawnJsonl({
80
84
  bin: BIN,
@@ -82,6 +86,11 @@ export default {
82
86
  cwd,
83
87
  env: { ...process.env, GROK_DISABLE_AUTOUPDATER: '1' },
84
88
  onEvent,
89
+ onStderr(chunk) {
90
+ const lines = (carry + chunk).split('\n');
91
+ carry = lines.pop();
92
+ for (const [, tool, detail] of lines.join('\n').matchAll(REFUSED)) onEvent({ type: 'blocked', tool, detail: detail.replace(/ \(workflow .*\)$/, '') });
93
+ },
85
94
  onJson(msg) {
86
95
  if (msg.session_id && msg.session_id !== session) onEvent({ type: 'session', id: (session = msg.session_id) });
87
96
  // Subagent traffic carries its parent's tool id; only the main conversation is shown.
@@ -137,7 +146,7 @@ export default {
137
146
  },
138
147
  onClose({ code, signal, stderr, stopped }) {
139
148
  if (sawResult) return;
140
- onEvent({ type: 'result', isError: true, stopped, text: error || stderr || `grok exited with ${signal || `code ${code}`}` });
149
+ onEvent({ type: 'result', isError: true, stopped, text: error || stderr || exitText('grok', code, signal) });
141
150
  },
142
151
  });
143
152
  handle.done.finally(() => fs.rmSync(tmp, { recursive: true, force: true }));
@@ -232,6 +241,12 @@ function describeMcp(tool, input) {
232
241
  return { kind: 'agent', label: `Delegating to ${clip([...new Set((input.tasks || []).map((t) => t.subagent))].join(', ') || 'subagents', 60)}` };
233
242
  case 'wisp__subagent_results':
234
243
  return { kind: 'agent', label: 'Waiting on subagents' };
244
+ case 'wisp__wisp_stats':
245
+ return { kind: 'tool', label: 'Reading wisp stats' };
246
+ case 'wisp__wisp_sessions':
247
+ return { kind: 'tool', label: 'Listing wisp tasks' };
248
+ case 'wisp__wisp_session':
249
+ return { kind: 'tool', label: `Reading wisp task ${clip(input?.id || '', 40)}` };
235
250
  default:
236
251
  return { kind: 'tool', label: `Using ${tool.split('__').join(' · ') || 'an integration'}` };
237
252
  }
@@ -7,9 +7,14 @@
7
7
  // efforts list wins); omit if the CLI has none
8
8
  // askTool: string | null | undefined how the agent sees wisp's ask_user MCP tool;
9
9
  // null = can't inject MCP, ask via a question block
10
+ // approveTool?: string its permission prompts go to wisp's `approve` MCP
11
+ // tool (live approval); omit if it can't route them
12
+ // permission?(tool, input, cwd): { tool, action, detail, why? } what a live approval is about
10
13
  // detect(): Promise<{ available, version?, error?, models? }> models overrides the static list
11
14
  // resumeCommand({ resumeId, model, effort }): string[] argv to continue the session interactively
12
- // run({ prompt, cwd, resumeId, mode, model, effort, systemPrompt, onEvent }): { stop(), done: Promise }
15
+ // run({ prompt, cwd, resumeId, mode, model, effort, systemPrompt, grants, onEvent }): { stop(), done: Promise }
16
+ // grants: `rule`s from its own blocked events the
17
+ // user allowed, for this turn only
13
18
  // }
14
19
  //
15
20
  // onEvent receives normalized events:
@@ -19,6 +24,8 @@
19
24
  // { type: 'text', text } intermediate assistant prose
20
25
  // { type: 'thinking' }
21
26
  // { type: 'change', file, op, edits: [{ before, after }] }
27
+ // { type: 'blocked', tool, detail, rule? } a call its permissions refused; rule: what allows
28
+ // exactly that on a retry (none: retry in Full auto)
22
29
  // { type: 'result', text, isError, stopped?, stats? } exactly once, at the end
23
30
  //
24
31
  // mode is one of: plan (read-only), edits (auto-accept file edits), auto (no permission prompts).
@@ -31,7 +38,23 @@ import cursor from './cursor.js';
31
38
  import antigravity from './antigravity.js';
32
39
  import grok from './grok.js';
33
40
 
34
- export const adapters = Object.fromEntries([claude, codex, opencode, cursor, antigravity, grok].map((a) => [a.id, a]));
41
+ // The shape above, checked when wisp starts: a missing piece fails loudly here, not mid-run.
42
+ function checked(a) {
43
+ const missing = [
44
+ typeof a.id !== 'string' && 'id',
45
+ typeof a.name !== 'string' && 'name',
46
+ !Array.isArray(a.models) && 'models',
47
+ a.askTool !== null && a.askTool !== undefined && typeof a.askTool !== 'string' && 'askTool',
48
+ a.approveTool !== undefined && (typeof a.approveTool !== 'string' || typeof a.permission !== 'function') && 'approveTool + permission()',
49
+ typeof a.detect !== 'function' && 'detect()',
50
+ typeof a.run !== 'function' && 'run()',
51
+ a.resumeCommand !== undefined && typeof a.resumeCommand !== 'function' && 'resumeCommand()',
52
+ ].filter(Boolean);
53
+ if (missing.length) throw new Error(`adapter ${a.id || '?'}: missing or wrong ${missing.join(', ')}`);
54
+ return a;
55
+ }
56
+
57
+ export const adapters = Object.fromEntries([claude, codex, opencode, cursor, antigravity, grok].map((a) => [a.id, checked(a)]));
35
58
 
36
59
  const detected = new Map();
37
60
 
@@ -1,8 +1,26 @@
1
1
  // opencode adapter — drives `opencode run --format json` (one JSON event per line).
2
2
 
3
- import { ASK_TIMEOUT_MS, attachmentNote, clip, detectVersion, fileTracker, quietOutput, rel, spawnJsonl, withProtocol } from './common.js';
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { toolsDir } from '../util.js';
7
+ import { ASK_TIMEOUT_MS, attachmentNote, clip, detectVersion, exitText, fileTracker, quietOutput, rel, spawnJsonl, withProtocol } from './common.js';
4
8
 
5
- const BIN = process.env.WISP_OPENCODE_BIN || 'opencode';
9
+ const exe = process.platform === 'win32' ? 'opencode.cmd' : 'opencode';
10
+ // Where opencode lives when it isn't on PATH: the copy wisp installs (POST /agents/install),
11
+ // and the official install script's folder (it edits your shell profile, which an app started
12
+ // from the Dock or a service never reads).
13
+ export const fallbackBins = () => [path.join(toolsDir(), 'node_modules', '.bin', exe), path.join(os.homedir(), '.opencode', 'bin', process.platform === 'win32' ? 'opencode.exe' : 'opencode')];
14
+ let BIN = process.env.WISP_OPENCODE_BIN || 'opencode';
15
+ // Rate-limited (the free models, mostly), `opencode run` keeps retrying and prints nothing, so
16
+ // the task would sit at "Starting up" for good. Its error log says so: give it this long to get
17
+ // through, then stop and tell the user what to do.
18
+ const rateLimitGrace = () => Number(process.env.WISP_RATE_LIMIT_GRACE_MS) || 60_000;
19
+ const RATE_LIMITED = /Rate limit exceeded|Too Many Requests|status(?:Code)?[=: ]+429\b/i;
20
+ // `run` answers every permission prompt itself and says so on stderr (cli/cmd/run.ts):
21
+ // "! permission requested: bash (npm publish); auto-rejecting".
22
+ const REJECTED = /permission requested: ([\w.-]+) \((.*?)\); auto-rejecting/g;
23
+ const ANSI = /\x1b\[[0-9;]*m/g;
6
24
 
7
25
  export default {
8
26
  id: 'opencode',
@@ -16,7 +34,16 @@ export default {
16
34
  },
17
35
 
18
36
  async detect() {
19
- const found = await detectVersion(BIN);
37
+ let found = await detectVersion(BIN);
38
+ if (!found.available && !process.env.WISP_OPENCODE_BIN) {
39
+ for (const bin of fallbackBins().filter((b) => fs.existsSync(b))) {
40
+ const there = await detectVersion(bin);
41
+ if (!there.available) continue;
42
+ BIN = bin;
43
+ found = there;
44
+ break;
45
+ }
46
+ }
20
47
  if (!found.available) return found;
21
48
  // `opencode models --verbose` prints each provider/model id followed by its JSON metadata;
22
49
  // the metadata's `variants` are the reasoning efforts `run --variant` accepts for that model.
@@ -38,9 +65,11 @@ export default {
38
65
  return { ...found, models: [this.models[0], ...models] };
39
66
  },
40
67
 
41
- run({ prompt, attachments = [], cwd, resumeId, mode, model, effort, systemPrompt, mcpServers = {}, delegating = false, onEvent }) {
68
+ // grants: { permission, patterns } the user allowed after `run` rejected them, for this turn.
69
+ run({ prompt, attachments = [], cwd, resumeId, mode, model, effort, systemPrompt, mcpServers = {}, delegating = false, grants = [], onEvent }) {
42
70
  // The prompt goes on stdin; `run` appends piped stdin to the (empty) message.
43
- const args = ['run', '--format', 'json'];
71
+ // Errors to stderr: the only place a rate limit shows up (see RATE_LIMITED).
72
+ const args = ['run', '--format', 'json', '--print-logs', '--log-level', 'ERROR'];
44
73
  if (mode === 'plan') args.push('--agent', 'plan');
45
74
  if (mode === 'auto') args.push('--auto');
46
75
  if (model) args.push('--model', model);
@@ -54,14 +83,20 @@ export default {
54
83
  mcp[name] = { type: 'local', command: [srv.command, ...(srv.args || [])], environment: srv.env || {}, enabled: true, timeout: ASK_TIMEOUT_MS };
55
84
  }
56
85
  const env = { ...process.env };
57
- if (Object.keys(mcp).length) {
86
+ const allow = grants.filter((g) => g && typeof g.permission === 'string' && Array.isArray(g.patterns));
87
+ if (Object.keys(mcp).length || allow.length) {
58
88
  const inline = env.OPENCODE_CONFIG_CONTENT ? safeJson(env.OPENCODE_CONFIG_CONTENT) : {};
89
+ const permission = typeof inline.permission === 'object' ? { ...inline.permission } : inline.permission ? { '*': inline.permission } : {};
90
+ // Its built-in `task` subagents would bypass the user's team.
91
+ if (delegating) permission.task = 'deny';
92
+ for (const g of allow) {
93
+ const was = permission[g.permission];
94
+ permission[g.permission] = { ...(typeof was === 'object' ? was : was && { '*': was }), ...Object.fromEntries(g.patterns.map((p) => [String(p), 'allow'])) };
95
+ }
59
96
  env.OPENCODE_CONFIG_CONTENT = JSON.stringify({
60
97
  ...inline,
61
- mcp: { ...inline.mcp, ...mcp },
62
- experimental: { ...inline.experimental, mcp_timeout: ASK_TIMEOUT_MS },
63
- // Its built-in `task` subagents would bypass the user's team.
64
- ...(delegating && { permission: { ...(typeof inline.permission === 'object' ? inline.permission : inline.permission && { '*': inline.permission }), task: 'deny' } }),
98
+ ...(Object.keys(mcp).length && { mcp: { ...inline.mcp, ...mcp }, experimental: { ...inline.experimental, mcp_timeout: ASK_TIMEOUT_MS } }),
99
+ ...(Object.keys(permission).length && { permission }),
65
100
  });
66
101
  }
67
102
 
@@ -75,14 +110,30 @@ export default {
75
110
  const tokens = { input: 0, output: 0 };
76
111
  let cost = 0;
77
112
 
78
- return spawnJsonl({
113
+ let limitTimer = null;
114
+ let limited = false;
115
+ let carry = ''; // a stderr line still being written
116
+ const proc = spawnJsonl({
79
117
  bin: BIN,
80
118
  args,
81
119
  cwd,
82
120
  env,
83
121
  input,
84
122
  onEvent,
123
+ onStderr(chunk) {
124
+ // Whole lines only: a chunk can end mid-line.
125
+ const lines = (carry + chunk).split('\n');
126
+ carry = lines.pop();
127
+ for (const [, permission, list] of lines.join('\n').replace(ANSI, '').matchAll(REJECTED)) {
128
+ const patterns = list.split(', ').filter(Boolean);
129
+ onEvent({ type: 'blocked', tool: permission, detail: patterns.join(', '), rule: { permission, patterns } });
130
+ }
131
+ if (limitTimer || limited || !RATE_LIMITED.test(chunk)) return;
132
+ limitTimer = setTimeout(() => ((limited = true), proc.stop()), rateLimitGrace());
133
+ },
85
134
  onJson(msg) {
135
+ // Anything coming through means a retry got an answer.
136
+ if (limitTimer && msg.type !== 'error') (clearTimeout(limitTimer), (limitTimer = null));
86
137
  if (msg.sessionID && msg.sessionID !== session) onEvent({ type: 'session', id: (session = msg.sessionID) });
87
138
  const part = msg.part || {};
88
139
  switch (msg.type) {
@@ -118,19 +169,28 @@ export default {
118
169
  }
119
170
  },
120
171
  onClose({ code, signal, stderr, stopped }) {
172
+ clearTimeout(limitTimer);
173
+ if (limited) {
174
+ const which = model ? `\`${model}\`` : 'opencode’s default model';
175
+ return onEvent({ type: 'result', isError: true, text: `${which} is rate-limited right now: opencode kept retrying for ${Math.round(rateLimitGrace() / 1000)}s without an answer. Try again in a few minutes, pick another model, or connect your own provider with \`opencode auth login\`.`, stats: { durationMs: Date.now() - started } });
176
+ }
121
177
  const ok = code === 0 && !error;
122
178
  onEvent({
123
179
  type: 'result',
124
180
  isError: !ok,
125
181
  stopped,
126
- text: ok ? text.join('\n') : error || stderr || `opencode exited with ${signal || `code ${code}`}`,
182
+ text: ok ? text.join('\n') : error || lastLoggedError(stderr) || exitText('opencode', code, signal),
127
183
  stats: { durationMs: Date.now() - started, costUsd: cost || undefined, inputTokens: tokens.input, outputTokens: tokens.output },
128
184
  });
129
185
  },
130
186
  });
187
+ return proc;
131
188
  },
132
189
  };
133
190
 
191
+ // stderr with --print-logs: the last logged error's message, else stderr as it is.
192
+ const lastLoggedError = (stderr) => [...stderr.matchAll(/error\.error="((?:[^"\\]|\\.)*)"/g)].at(-1)?.[1] || stderr;
193
+
134
194
  function safeJson(s) {
135
195
  try {
136
196
  return JSON.parse(s) || {};
@@ -175,6 +235,12 @@ function describeTool(name, input, cwd) {
175
235
  return { kind: 'agent', label: `Delegated to ${clip([...new Set((input.tasks || []).map((t) => t.subagent))].join(', ') || 'subagents', 60)}` };
176
236
  case 'wisp_subagent_results':
177
237
  return { kind: 'agent', label: 'Collected subagent reports' };
238
+ case 'wisp_wisp_stats':
239
+ return { kind: 'tool', label: 'Reading wisp stats' };
240
+ case 'wisp_wisp_sessions':
241
+ return { kind: 'tool', label: 'Listing wisp tasks' };
242
+ case 'wisp_wisp_session':
243
+ return { kind: 'tool', label: `Reading wisp task ${clip(input?.id || '', 40)}` };
178
244
  default:
179
245
  return { kind: 'tool', label: `Using ${name.replace(/_/g, ' ')}` };
180
246
  }
@@ -1,22 +1,13 @@
1
- // Per-turn git checkpoints, single-file revert, turn rollback, and action command execution.
1
+ // Per-turn git checkpoints, single-file revert and turn rollback.
2
2
 
3
- import { execFileSync, spawn as nodeSpawn } from 'node:child_process';
4
3
  import fs from 'node:fs';
5
4
  import path from 'node:path';
6
- import { killTree } from './proc.js';
7
- import { MAX_SNAPSHOT, rel } from './adapters/common.js';
5
+ import { gitSync } from './git.js';
6
+ import { MAX_SNAPSHOT, rel } from './paths.js';
7
+ import { bad } from './util.js';
8
8
 
9
- const MAX_EXEC_OUTPUT = 32 * 1024;
10
- const EXEC_TIMEOUT_MS = 60_000;
11
9
 
12
- function git(cwd, args, opts = {}) {
13
- return execFileSync('git', args, {
14
- cwd,
15
- stdio: ['ignore', 'pipe', 'ignore'],
16
- timeout: 5000,
17
- ...opts,
18
- });
19
- }
10
+ const git = gitSync;
20
11
 
21
12
  function tryGit(cwd, args, opts) {
22
13
  try {
@@ -100,12 +91,12 @@ function writeDiskSnapshot(abs, content) {
100
91
 
101
92
  function resolveInsideCwd(cwd, file) {
102
93
  if (!file || typeof file !== 'string') {
103
- throw Object.assign(new Error('file path required'), { status: 400 });
94
+ throw bad('file path required');
104
95
  }
105
96
  const abs = path.resolve(cwd, file);
106
97
  const relPath = path.relative(cwd, abs);
107
98
  if (!relPath || relPath.startsWith('..') || path.isAbsolute(relPath)) {
108
- throw Object.assign(new Error(`File lies outside the project folder: ${file}`), { status: 400 });
99
+ throw bad(`File lies outside the project folder: ${file}`);
109
100
  }
110
101
  return { abs, relPath: rel(file, cwd) || relPath };
111
102
  }
@@ -128,7 +119,7 @@ export function revertFileChange(session, turn, file, { restore = false } = {})
128
119
  const entry = findChangeEntry(turn, file, cwd);
129
120
  const commit = turn.checkpoint?.commit;
130
121
  if (!entry && !commit) {
131
- throw Object.assign(new Error(`No captured changes or checkpoint for ${relPath}`), { status: 404 });
122
+ throw bad(`No captured changes or checkpoint for ${relPath}`, 404);
132
123
  }
133
124
 
134
125
  if (restore) {
@@ -145,7 +136,7 @@ export function revertFileChange(session, turn, file, { restore = false } = {})
145
136
  writeDiskSnapshot(abs, cur);
146
137
  }
147
138
  } else {
148
- throw Object.assign(new Error(`Nothing to re-apply for ${relPath}`), { status: 400 });
139
+ throw bad(`Nothing to re-apply for ${relPath}`);
149
140
  }
150
141
  if (entry) entry.reverted = false;
151
142
  if (turn.changes?.every((c) => !c.reverted)) delete turn.rolledBackAt;
@@ -200,7 +191,7 @@ function reverseEditsOnDisk(abs, entry) {
200
191
  export function rollbackToTurn(session, turn, { restore = false } = {}) {
201
192
  const cwd = sessionDir(session);
202
193
  const idx = session.turns.indexOf(turn);
203
- if (idx < 0) throw Object.assign(new Error('No such turn'), { status: 404 });
194
+ if (idx < 0) throw bad('No such turn', 404);
204
195
  const affected = session.turns.slice(idx);
205
196
  const touchedFiles = [...new Set(affected.flatMap((t) => (t.changes || []).map((c) => c.file)))];
206
197
 
@@ -297,52 +288,5 @@ export function rollbackToTurn(session, turn, { restore = false } = {}) {
297
288
  return { files: touchedFiles, rolledBack: true, commit: cp?.head || cp?.commit || null };
298
289
  }
299
290
 
300
- // Run a shell command from an actionable presentation block inside `cwd`.
301
- export function execCommand(cwd, command, { timeoutMs = EXEC_TIMEOUT_MS } = {}) {
302
- const cmd = String(command || '').trim();
303
- if (!cmd) return Promise.reject(Object.assign(new Error('command required'), { status: 400 }));
304
- const t0 = Date.now();
305
-
306
- return new Promise((resolve) => {
307
- const child = nodeSpawn(cmd, {
308
- cwd,
309
- env: { ...process.env, PAGER: 'cat', GIT_TERMINAL_PROMPT: '0', CI: '1' },
310
- shell: true,
311
- windowsHide: true,
312
- stdio: ['ignore', 'pipe', 'pipe'],
313
- });
314
- let out = '';
315
- const append = (chunk) => {
316
- if (out.length < MAX_EXEC_OUTPUT) out += chunk.toString('utf8').slice(0, MAX_EXEC_OUTPUT - out.length);
317
- };
318
- child.stdout.on('data', append);
319
- child.stderr.on('data', append);
320
-
321
- let timedOut = false;
322
- const timer = setTimeout(() => {
323
- timedOut = true;
324
- killTree(child);
325
- }, timeoutMs);
326
-
327
- child.on('error', (err) => {
328
- clearTimeout(timer);
329
- resolve({
330
- command: cmd,
331
- output: (out ? `${out}\n` : '') + err.message,
332
- exitCode: 1,
333
- durationMs: Date.now() - t0,
334
- });
335
- });
336
-
337
- child.on('close', (code) => {
338
- clearTimeout(timer);
339
- const suffix = timedOut ? `\n[timed out after ${Math.round(timeoutMs / 1000)}s]` : '';
340
- resolve({
341
- command: cmd,
342
- output: (out.replace(/\s+$/, '') + suffix).trim(),
343
- exitCode: timedOut ? 124 : (code ?? 1),
344
- durationMs: Date.now() - t0,
345
- });
346
- });
347
- });
348
- }
291
+ // Moved to proc.js; kept here for anything that still imports it from checkpoint.
292
+ export { execCommand } from './proc.js';
package/server/client.js CHANGED
@@ -13,8 +13,9 @@ import fs from 'node:fs';
13
13
  import os from 'node:os';
14
14
  import path from 'node:path';
15
15
  import { fileURLToPath } from 'node:url';
16
+ import { DEFAULT_PORT } from './util.js';
16
17
 
17
- export const DEFAULT_PORT = 4477;
18
+ export { DEFAULT_PORT };
18
19
  const BIN = fileURLToPath(new URL('../bin/wisp.js', import.meta.url));
19
20
  const MAX_WAIT_CALL = 280; // seconds per long-poll; fetch gives up on headers after 300s
20
21
 
@@ -135,7 +136,7 @@ export class WispClient {
135
136
  if (!agent || !mode || model === undefined) {
136
137
  const { defaults = {} } = await this.config();
137
138
  agent ||= defaults.agent || 'auto';
138
- mode ||= defaults.mode || 'edits';
139
+ mode ||= defaults.mode || 'auto';
139
140
  model ??= defaults.models?.[agent] ?? (agent === defaults.agent ? defaults.model || '' : '');
140
141
  }
141
142
  return this.request('POST', '/sessions', { prompt, cwd: path.resolve(cwd), agent, mode, model, effort, attachments, worktree, compare, subagents, preset });
@@ -154,8 +155,9 @@ export class WispClient {
154
155
  usage = ({ days } = {}) => this.request('GET', `/usage${days ? `?days=${days}` : ''}`);
155
156
 
156
157
  // A follow-up. If the agent is blocked on ask_user, this answers it. queue: wait for the
157
- // current run instead of interrupting it (default: the user's setting).
158
- reply = (id, text, { attachments, queue, mode } = {}) => this.request('POST', `/sessions/${id}/reply`, { text, attachments, queue, mode });
158
+ // current run instead of interrupting it (default: the user's setting). agent / model / effort:
159
+ // run it, and the follow-ups after it, on another harness, model or thinking level.
160
+ reply = (id, text, { attachments, queue, mode, agent, model, effort } = {}) => this.request('POST', `/sessions/${id}/reply`, { text, attachments, queue, mode, agent, model, effort });
159
161
 
160
162
  // Upload a file (path, Buffer or Uint8Array) to attach to a launch or reply. Returns { id, … }.
161
163
  async upload(file, { name, type } = {}) {
package/server/git.js CHANGED
@@ -3,7 +3,9 @@
3
3
 
4
4
  import fs from 'node:fs';
5
5
  import path from 'node:path';
6
+ import { execFileSync } from 'node:child_process';
6
7
  import { execFile } from './proc.js';
8
+ import { bad } from './util.js';
7
9
 
8
10
  const GIT = () => process.env.WISP_GIT_BIN || 'git';
9
11
  const GH = () => process.env.WISP_GH_BIN || 'gh';
@@ -22,6 +24,9 @@ export function run(bin, args, { cwd, timeout = 60_000 } = {}) {
22
24
  });
23
25
  }
24
26
  export const git = (cwd, args, opts) => run(GIT(), args, { cwd, ...opts });
27
+ // Synchronous, for the few places that must not yield (checkpoints taken right before a turn
28
+ // starts, file snapshots while a harness streams). Returns a Buffer; throws on failure.
29
+ export const gitSync = (cwd, args, opts = {}) => execFileSync(GIT(), args, { cwd, stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000, env: { ...process.env, ...QUIET_ENV }, ...opts });
25
30
  export const gh = (cwd, args, opts) => run(GH(), args, { cwd, timeout: 120_000, ...opts });
26
31
  const quiet = (p) => p.catch(() => '');
27
32
 
@@ -161,13 +166,13 @@ export async function ship(wt, { title, body = '', draft = false, pr }) {
161
166
  await ensureWorktree(wt);
162
167
  const dir = wt.path;
163
168
  if (await quiet(git(dir, ['status', '--porcelain']))) {
164
- if (!String(title || '').trim()) throw Object.assign(new Error('A commit message is required.'), { status: 400 });
169
+ if (!String(title || '').trim()) throw bad('A commit message is required.');
165
170
  await git(dir, ['add', '-A']);
166
171
  await git(dir, ['commit', '--quiet', '--no-verify', '-m', title.trim(), ...(body.trim() ? ['-m', body.trim()] : [])]);
167
172
  }
168
173
  const { ahead } = await worktreeStatus(wt);
169
- if (!ahead) throw Object.assign(new Error('Nothing to ship: this branch has no changes.'), { status: 400 });
170
- if (!wt.remote) throw Object.assign(new Error('This repo has no origin remote to push to.'), { status: 400 });
174
+ if (!ahead) throw bad('Nothing to ship: this branch has no changes.');
175
+ if (!wt.remote) throw bad('This repo has no origin remote to push to.');
171
176
  await git(dir, ['push', '--quiet', '-u', wt.remote, `HEAD:refs/heads/${wt.branch}`], { timeout: 120_000 });
172
177
  if (pr?.state === 'OPEN') return prView(dir, pr.number);
173
178
  const url = (await gh(dir, ['pr', 'create', '--base', wt.base, '--head', wt.branch, '--title', title.trim(), '--body', body.trim() || ' ', ...(draft ? ['--draft'] : [])])).split('\n').pop();
@@ -178,7 +183,7 @@ export async function ship(wt, { title, body = '', draft = false, pr }) {
178
183
  export async function merge(wt, number) {
179
184
  const dir = fs.existsSync(wt.path) ? wt.path : wt.root;
180
185
  let pr = await prView(dir, number);
181
- if (pr.state !== 'OPEN') throw Object.assign(new Error(`PR #${number} is ${pr.state.toLowerCase()}.`), { status: 400 });
186
+ if (pr.state !== 'OPEN') throw bad(`PR #${number} is ${pr.state.toLowerCase()}.`);
182
187
  if (pr.draft) await gh(dir, ['pr', 'ready', String(number)]);
183
188
  await gh(dir, ['pr', 'merge', String(number), '--squash', '--match-head-commit', pr.head]);
184
189
  pr = await prView(dir, number);
@@ -0,0 +1,71 @@
1
+ // Switching harness in a follow-up. Each harness keeps its own conversation (native session id),
2
+ // so a harness picked up mid-task hasn't seen the turns that ran elsewhere. handoffNote() writes
3
+ // those turns out (requests, reports, files touched) and goes ahead of the follow-up's prompt.
4
+ // A harness that ran earlier in the task resumes its own session and only hears what it missed.
5
+
6
+ import { textMentions } from './mentions.js';
7
+ import { blocksToMarkdown } from './render.js';
8
+ import { clip, truncate } from './util.js';
9
+
10
+ const BUDGET = 24_000; // characters of earlier turns, newest kept first
11
+ const PER_TURN = 6_000;
12
+ // The parts of a report worth passing on. Diffs, code and terminal output are on disk or in git.
13
+ const KEEP = new Set(['hero', 'markdown', 'steps', 'stats', 'files', 'tests', 'commits', 'checklist', 'callout', 'question', 'next']);
14
+
15
+ // The turns before the current one that `agent` hasn't seen: everything after the last turn it
16
+ // ran, or all of them when it has no conversation of its own to resume (resumeId). Only a gap
17
+ // that another harness filled counts: on its own, a harness has nothing to catch up on.
18
+ export function missedTurns(session, agent, resumeId) {
19
+ const prior = session.turns.slice(0, -1);
20
+ const missed = resumeId ? prior.slice(prior.findLastIndex((t) => (t.agent || agent) === agent) + 1) : prior;
21
+ return missed.some((t) => (t.agent || agent) !== agent) ? missed : [];
22
+ }
23
+
24
+ function turnText(t, n, name) {
25
+ const blocks = (t.presentation?.blocks || []).filter((b) => KEEP.has(b?.type));
26
+ const report = t.status === 'running' ? '' : blocksToMarkdown(blocks, t) || clip(t.raw, 1500);
27
+ const files = (t.changes || []).filter((c) => !c.reverted).map((c) => `\`${c.file}\``);
28
+ return truncate(
29
+ [
30
+ `### Turn ${n} · ${name(t)}${t.model ? ` (${t.model})` : ''}${t.status === 'stopped' ? ' · stopped' : t.status === 'error' ? ' · failed' : ''}${t.rolledBackAt ? ' · rolled back, its edits were undone' : ''}`,
31
+ `**User:** ${textMentions(t.prompt)}`,
32
+ report && `**Report:**\n\n${report}`,
33
+ files.length && `**Files it edited:** ${files.join(', ')}`,
34
+ ]
35
+ .filter(Boolean)
36
+ .join('\n\n'),
37
+ PER_TURN,
38
+ );
39
+ }
40
+
41
+ // The note for `agent` taking the next turn, or '' when it has seen everything.
42
+ // name(turn) → the harness's display name for that turn.
43
+ export function handoffNote(session, agent, resumeId, name) {
44
+ const missed = missedTurns(session, agent, resumeId);
45
+ if (!missed.length) return '';
46
+ const all = session.turns.slice(0, -1);
47
+ const parts = [];
48
+ let used = 0;
49
+ for (let i = missed.length - 1; i >= 0; i--) {
50
+ const text = turnText(missed[i], all.indexOf(missed[i]) + 1, name);
51
+ if (used + text.length > BUDGET && parts.length) break;
52
+ parts.unshift(text);
53
+ used += text.length;
54
+ }
55
+ const dropped = missed.length - parts.length;
56
+ const fresh = !resumeId;
57
+ const others = [...new Set(missed.filter((t) => t.agent !== agent).map(name))].join(', ');
58
+ const intro = fresh
59
+ ? `You are taking over a task that another coding agent (${others}) has been working on. Its work is already in this folder. Below is what happened so far, oldest first; read the files and \`git diff\` for the details, then carry on with the follow-up.`
60
+ : `While you were away, this task continued on another coding agent (${others}). Its work is already in this folder. Below is what happened since your last turn, oldest first; check the files and \`git diff\` before you rely on what you remember.`;
61
+ return [
62
+ '## Earlier in this task',
63
+ intro,
64
+ dropped ? `_${dropped} earlier turn${dropped > 1 ? 's' : ''} left out for length._` : '',
65
+ ...parts,
66
+ '---',
67
+ '## Follow-up',
68
+ ]
69
+ .filter(Boolean)
70
+ .join('\n\n');
71
+ }
@@ -0,0 +1,59 @@
1
+ // Writing responses and reading request bodies. The API speaks JSON; a few routes stream.
2
+
3
+ const MAX_BODY = 2e6;
4
+
5
+ export function send(res, status, body) {
6
+ res.writeHead(status, { 'content-type': 'application/json' });
7
+ res.end(JSON.stringify(body));
8
+ }
9
+
10
+ export function readJson(req) {
11
+ return new Promise((resolve, reject) => {
12
+ let data = '';
13
+ let tooLarge = false;
14
+ req.on('data', (c) => {
15
+ if (tooLarge) return;
16
+ data += c;
17
+ if (data.length > MAX_BODY) {
18
+ // Stop buffering: the rest of the body is dropped, not held in memory.
19
+ tooLarge = true;
20
+ data = '';
21
+ reject(Object.assign(new Error('body too large'), { status: 413 }));
22
+ }
23
+ });
24
+ req.on('end', () => {
25
+ if (tooLarge) return;
26
+ try {
27
+ resolve(data ? JSON.parse(data) : {});
28
+ } catch {
29
+ reject(Object.assign(new Error('invalid json'), { status: 400 }));
30
+ }
31
+ });
32
+ });
33
+ }
34
+
35
+ // Pass a machine's raw response (images, markdown, uploads) straight through.
36
+ export function pipeFrom(res, r) {
37
+ const headers = { 'content-type': r.headers['content-type'] || 'application/octet-stream' };
38
+ if (r.headers['content-length']) headers['content-length'] = r.headers['content-length'];
39
+ res.writeHead(r.statusCode, headers);
40
+ r.pipe(res);
41
+ }
42
+
43
+ // Server-sent events: every open /api/events stream gets every broadcast.
44
+ export function eventStream() {
45
+ const clients = new Set();
46
+ return {
47
+ clients,
48
+ attach(req, res) {
49
+ res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' });
50
+ res.write(`event: hello\ndata: {}\n\n`);
51
+ clients.add(res);
52
+ req.on('close', () => clients.delete(res));
53
+ },
54
+ broadcast(event, data) {
55
+ const frame = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
56
+ for (const res of clients) res.write(frame);
57
+ },
58
+ };
59
+ }