wisp-agents 0.21.0 → 0.22.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 (57) hide show
  1. package/CHANGELOG.md +24 -1
  2. package/README.md +13 -8
  3. package/bin/wisp.js +10 -3
  4. package/package.json +4 -3
  5. package/server/adapters/claude.js +8 -4
  6. package/server/adapters/codex.js +9 -6
  7. package/server/adapters/cursor.js +4 -1
  8. package/server/adapters/grok.js +2 -1
  9. package/server/adapters/index.js +7 -2
  10. package/server/adapters/opencode.js +5 -2
  11. package/server/btw.js +76 -0
  12. package/server/client.js +19 -0
  13. package/server/git.js +80 -4
  14. package/server/handoff.js +13 -7
  15. package/server/index.js +4 -0
  16. package/server/machines.js +15 -4
  17. package/server/openapi.js +27 -2
  18. package/server/remote.js +4 -2
  19. package/server/routes/machines.js +1 -1
  20. package/server/routes/proxy.js +6 -0
  21. package/server/routes/sessions.js +32 -2
  22. package/server/routes/settings.js +24 -38
  23. package/server/routes/uploads.js +2 -0
  24. package/server/runner.js +154 -8
  25. package/server/services/launch.js +32 -4
  26. package/server/services/move.js +130 -0
  27. package/server/services/phone.js +20 -1
  28. package/server/services/places.js +150 -0
  29. package/server/services/uploads.js +23 -4
  30. package/server/store.js +39 -6
  31. package/skills/wisp/SKILL.md +1 -0
  32. package/web/css/blocks.css +2 -0
  33. package/web/css/composer.css +23 -1
  34. package/web/css/feed.css +49 -1
  35. package/web/css/machines.css +4 -0
  36. package/web/css/modals.css +19 -0
  37. package/web/css/ship.css +2 -0
  38. package/web/css/transcript.css +115 -0
  39. package/web/index.html +2 -0
  40. package/web/js/core/state.js +3 -2
  41. package/web/js/features/actions.js +42 -10
  42. package/web/js/features/btw.js +145 -0
  43. package/web/js/features/clones.js +7 -6
  44. package/web/js/features/commands.js +3 -1
  45. package/web/js/features/composer.js +162 -18
  46. package/web/js/features/feed.js +1 -1
  47. package/web/js/features/keys.js +2 -1
  48. package/web/js/features/launcher.js +104 -94
  49. package/web/js/features/place-menu.js +44 -0
  50. package/web/js/features/places.js +15 -5
  51. package/web/js/features/projects.js +14 -12
  52. package/web/js/features/settings.js +24 -3
  53. package/web/js/features/slash.js +63 -0
  54. package/web/js/features/transcript.js +290 -0
  55. package/web/js/features/turns.js +99 -28
  56. package/web/js/slash-text.js +22 -0
  57. package/web/js/ui/kbd.js +1 -0
@@ -0,0 +1,150 @@
1
+ // Places: this computer and the machines. A project is one repo that every place can run, so a
2
+ // task launched on a machine that has no checkout of it yet gets one first: a clone of the same
3
+ // repo that's already there (registered or in the usual folders), else a fresh clone of its
4
+ // origin. The checkout is remembered as the project's copy on that machine (store.setCopy).
5
+
6
+ import fs from 'node:fs';
7
+ import os from 'node:os';
8
+ import path from 'node:path';
9
+ import { cloneRepo, git, repoOf, sameRepo } from '../git.js';
10
+ import { HERE } from '../store.js';
11
+ import { bad, expand } from '../util.js';
12
+
13
+ const CLONE_TIMEOUT_MS = 10 * 60_000;
14
+
15
+ // Where people keep their repos, for one-click "add project".
16
+ export const REPO_ROOTS = ['code', 'Code', 'dev', 'Developer', 'projects', 'Projects', 'src', 'repos', 'work', 'git', 'GitHub', 'Documents', 'Downloads', 'Desktop'];
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
+
28
+ // Git repos up to two levels under the usual folders, newest first, skipping known ones.
29
+ export function discoverRepos(known = new Set()) {
30
+ const found = [];
31
+ const walk = (dir, depth) => {
32
+ if (found.length >= 60) return;
33
+ let entries;
34
+ try {
35
+ entries = fs.readdirSync(dir, { withFileTypes: true });
36
+ } catch {
37
+ return;
38
+ }
39
+ if (entries.some((e) => e.name === '.git')) {
40
+ if (!known.has(dir)) found.push({ path: dir, name: path.basename(dir), mtime: fs.statSync(dir).mtimeMs });
41
+ return;
42
+ }
43
+ if (depth <= 0) return;
44
+ for (const e of entries) if (e.isDirectory() && !e.name.startsWith('.') && e.name !== 'node_modules') walk(path.join(dir, e.name), depth - 1);
45
+ };
46
+ for (const r of REPO_ROOTS) walk(path.join(os.homedir(), r), 2);
47
+ return found.sort((a, b) => b.mtime - a.mtime).slice(0, 24);
48
+ }
49
+
50
+ export const originAt = (dir) => git(dir, ['remote', 'get-url', 'origin'], { timeout: 5000 }).catch(() => '');
51
+
52
+ // A checkout of `url` on this computer: a project (or copy) here that is that repo, else one in
53
+ // the usual folders, else a fresh clone into the clone folder. Resolves { dir, existed }.
54
+ export async function checkout(store, url) {
55
+ const repo = repoOf(url);
56
+ if (!repo) throw bad(`Not a repo URL: ${url}`);
57
+ const mine = store.config.projects.map((p) => [p, store.pathAt(p)]).filter(([, dir]) => dir && fs.existsSync(dir));
58
+ for (const [p, dir] of mine) if (sameRepo(p.repo || (await originAt(dir)), url)) return { dir, existed: true };
59
+ const found = discoverRepos();
60
+ const origins = await Promise.all(found.map((f) => originAt(f.path)));
61
+ const hit = found.find((f, i) => sameRepo(origins[i], url));
62
+ if (hit) return { dir: hit.path, existed: true };
63
+ return cloneRepo(repo, cloneRoot(store.config), { timeout: CLONE_TIMEOUT_MS });
64
+ }
65
+
66
+ export function createPlaces(app) {
67
+ const { store, machines } = app;
68
+ const placeName = (mid) => (mid ? machines.get(mid).name : 'this computer');
69
+ const inflight = new Map(); // `${project}@${place}` -> promise, so two launches clone once
70
+
71
+ // The project's origin URL, asked of whichever place has a checkout; kept on the project.
72
+ async function originOf(p) {
73
+ if (p.repo) return p.repo;
74
+ const places = [p.machine || null, ...Object.keys(p.copies || {}).map((k) => (k === HERE ? null : k))];
75
+ for (const mid of places) {
76
+ const dir = store.pathAt(p, mid);
77
+ let url = '';
78
+ if (!mid) url = fs.existsSync(dir) ? await originAt(dir) : '';
79
+ else if (machines.isOnline(mid)) url = (await machines.json(mid, 'GET', `/projects/origin?path=${encodeURIComponent(dir)}`).catch(() => null))?.origin || '';
80
+ if (url) {
81
+ p.repo = url;
82
+ store.saveConfig();
83
+ return url;
84
+ }
85
+ }
86
+ return '';
87
+ }
88
+
89
+ async function remoteCheckout(mid, repo, name) {
90
+ try {
91
+ const r = await machines.json(mid, 'POST', '/projects/checkout', { repo, name }, { timeout: CLONE_TIMEOUT_MS });
92
+ return { dir: r.path, existed: !!r.existed };
93
+ } catch (err) {
94
+ if (err.status !== 404) throw err;
95
+ }
96
+ // An older wisp there: its POST /projects clones the repo (or reuses a clone of it).
97
+ const r = await machines.json(mid, 'POST', '/projects', { repo }, { timeout: CLONE_TIMEOUT_MS });
98
+ return { dir: r.path, existed: !!r.cloned?.existed };
99
+ }
100
+
101
+ // The project's folder on `mid` (null = this computer). fresh: the one wisp knew is gone.
102
+ async function ensureAt(p, mid = null, { fresh = false } = {}) {
103
+ const known = store.pathAt(p, mid);
104
+ if (known && !fresh && (mid || fs.existsSync(known))) return known;
105
+ const key = `${p.id}@${mid || HERE}`;
106
+ if (!inflight.has(key)) {
107
+ const job = (async () => {
108
+ const repo = await originOf(p);
109
+ if (!repo) throw bad(`${p.name} has no git remote (origin) to clone from, so wisp can’t set it up on ${placeName(mid)}. Push it to a remote first, or add its folder there in Projects.`, 409);
110
+ const out = mid ? await remoteCheckout(mid, repo, p.name) : await checkout(store, repo);
111
+ store.setCopy(p, mid, out.dir);
112
+ app.configChanged();
113
+ return out.dir;
114
+ })().finally(() => inflight.delete(key));
115
+ inflight.set(key, job);
116
+ }
117
+ return inflight.get(key);
118
+ }
119
+
120
+ // A task's folder, for running it on `mid`: the same folder in that place's checkout of its
121
+ // project. Folders that are no project's (and ones already on that place) stay as they are.
122
+ async function dirAt(dir, mid = null) {
123
+ if (!dir) return dir;
124
+ if (!mid) dir = expand(dir);
125
+ if (store.projectFor(dir, mid)) return dir;
126
+ for (const from of [null, ...machines.list.map((m) => m.id)]) {
127
+ if (from === (mid || null)) continue;
128
+ const p = store.projectFor(dir, from);
129
+ if (!p) continue;
130
+ // A folder that's here too (the same layout on both computers) is used as it is, unless the
131
+ // project already has its own checkout here.
132
+ if (!mid && fs.existsSync(dir) && !store.pathAt(p)) return dir;
133
+ const rest = dir.slice(store.pathAt(p, from).length).split(/[\\/]/).filter(Boolean);
134
+ const there = await ensureAt(p, mid);
135
+ return rest.length ? (mid ? path.posix : path).join(there, ...rest) : there;
136
+ }
137
+ return dir;
138
+ }
139
+
140
+ // A launch body with its folders (cwd, also) moved to `mid`.
141
+ async function placeBody(body, mid = null) {
142
+ if (body.scratch === true) return body;
143
+ const out = { ...body };
144
+ if (body.cwd) out.cwd = await dirAt(String(body.cwd), mid);
145
+ if (Array.isArray(body.also)) out.also = await Promise.all(body.also.map((x) => (typeof x === 'string' ? dirAt(x, mid) : x?.cwd ? dirAt(String(x.cwd), mid) : x)));
146
+ return out;
147
+ }
148
+
149
+ return { originOf, ensureAt, dirAt, placeBody, placeName };
150
+ }
@@ -31,6 +31,28 @@ export class Uploads {
31
31
  return Array.isArray(ids) ? ids.map((i) => this.byId.get(i)).filter(Boolean) : [];
32
32
  }
33
33
 
34
+ // A file to fill in here (a moved task's bundle): its path, and add() once it's written.
35
+ reserve(name) {
36
+ const id = newId();
37
+ const file = path.join(this.dir, `${id}-${name}`);
38
+ return { id, file, add: () => this.#record({ id, name, size: fs.statSync(file).size, mime: 'application/octet-stream', path: file }) };
39
+ }
40
+
41
+ remove(id) {
42
+ const u = this.byId.get(id);
43
+ if (!u) return false;
44
+ this.byId.delete(id);
45
+ fs.rmSync(u.path, { force: true });
46
+ fs.rmSync(path.join(this.dir, `${id}.json`), { force: true });
47
+ return true;
48
+ }
49
+
50
+ #record(meta) {
51
+ this.byId.set(meta.id, meta);
52
+ fs.writeFileSync(path.join(this.dir, `${meta.id}.json`), JSON.stringify(meta));
53
+ return meta;
54
+ }
55
+
34
56
  async receive(req, { name, type }) {
35
57
  name = path.basename(name || 'file').replace(/[^\w.\- ]+/g, '_').slice(-120) || 'file';
36
58
  const id = newId();
@@ -51,9 +73,6 @@ export class Uploads {
51
73
  out.on('finish', resolve);
52
74
  out.on('error', reject);
53
75
  });
54
- const meta = { id, name, size, mime: type || MIME[path.extname(name).toLowerCase()] || 'application/octet-stream', path: file };
55
- this.byId.set(id, meta);
56
- fs.writeFileSync(path.join(this.dir, `${id}.json`), JSON.stringify(meta));
57
- return meta;
76
+ return this.#record({ id, name, size, mime: type || MIME[path.extname(name).toLowerCase()] || 'application/octet-stream', path: file });
58
77
  }
59
78
  }
package/server/store.js CHANGED
@@ -4,11 +4,12 @@ import fs from 'node:fs';
4
4
  import path from 'node:path';
5
5
  import os from 'node:os';
6
6
  import { randomUUID } from 'node:crypto';
7
+ import { sameRepo } from './git.js';
7
8
  import { parsePresentation } from './protocol.js';
8
9
  import { writeJsonAtomic } from './util.js';
9
10
 
10
11
  const DEFAULT_CONFIG = {
11
- projects: [], // [{ id, name, path, color, addedAt, machine? }]; machine: the server/machines.js id it lives on
12
+ projects: [], // [{ id, name, path, color, addedAt, machine?, repo?, copies? }]: see projectFor()
12
13
  defaults: { agent: 'auto', mode: 'auto', model: '', effort: '' }, // agent auto: wisp picks harness and model (server/router.js)
13
14
  recipes: [], // saved prompts with {params}: server/recipes.js
14
15
  triggers: [], // schedules and GitHub watchers: server/triggers.js
@@ -20,6 +21,9 @@ const DEFAULT_CONFIG = {
20
21
  const IMAGE_EXT = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg']);
21
22
  const MAX_KEPT_IMAGE = 25 * 1024 * 1024;
22
23
 
24
+ // The key for this computer in a project's copies (machine ids are 6 hex characters).
25
+ export const HERE = 'here';
26
+
23
27
  export const PROJECT_COLORS = ['violet', 'teal', 'amber', 'rose', 'sky', 'lime', 'orange', 'indigo'];
24
28
 
25
29
  export class Store {
@@ -85,26 +89,47 @@ export class Store {
85
89
  }
86
90
 
87
91
  // --- projects: plain folders, matched to sessions by path prefix ---
92
+ //
93
+ // A project is one repo, and every place can run it. path (on machine, null = this computer) is
94
+ // where it was added; copies are its checkouts elsewhere, which wisp found or cloned there the
95
+ // first time a task ran there: { [machine id, or HERE]: path }. repo: its origin URL, once known.
96
+
97
+ // The project's folder on `machine` (null = this computer), or null when it has none there yet.
98
+ pathAt(p, machine = null) {
99
+ return (p.machine || null) === (machine || null) ? p.path : p.copies?.[machine || HERE] || null;
100
+ }
88
101
 
89
- // machine: a project on another computer (paths there are POSIX); null = this one.
102
+ // machine: a folder on another computer (paths there are POSIX); null = this one.
90
103
  projectFor(cwd = '', machine = null) {
91
104
  if (!machine && this.isScratch(cwd)) return null; // never inside a project, even one at ~
92
105
  const sep = machine ? '/' : path.sep;
93
106
  let best = null;
94
- for (const p of this.config.projects)
95
- if ((p.machine || null) === machine && (cwd === p.path || cwd.startsWith(p.path + sep)) && (!best || p.path.length > best.path.length)) best = p;
107
+ let len = 0;
108
+ for (const p of this.config.projects) {
109
+ const dir = this.pathAt(p, machine);
110
+ if (dir && (cwd === dir || cwd.startsWith(dir + sep)) && dir.length > len) [best, len] = [p, dir.length];
111
+ }
96
112
  return best;
97
113
  }
98
114
 
99
- addProject(dir, { name, color, machine } = {}) {
100
- const existing = this.config.projects.find((p) => p.path === dir && (p.machine || null) === (machine || null));
115
+ // repo: the folder's origin URL. A folder of a repo that's already a project becomes that
116
+ // project's copy on this place, so the same repo is one project wherever it's checked out.
117
+ addProject(dir, { name, color, machine, repo } = {}) {
118
+ const existing = this.config.projects.find((p) => this.pathAt(p, machine) === dir);
101
119
  if (existing) return existing;
120
+ const same = repo && this.config.projects.find((p) => p.repo && sameRepo(p.repo, repo) && !this.pathAt(p, machine));
121
+ if (same) {
122
+ (same.copies ||= {})[machine || HERE] = dir;
123
+ this.saveConfig();
124
+ return same;
125
+ }
102
126
  const used = new Set(this.config.projects.map((p) => p.color));
103
127
  const project = {
104
128
  id: Math.random().toString(36).slice(2, 10),
105
129
  name: name || (machine ? path.posix.basename(dir) : path.basename(dir)) || dir,
106
130
  path: dir,
107
131
  ...(machine && { machine }),
132
+ ...(repo && { repo }),
108
133
  color: color || PROJECT_COLORS.find((c) => !used.has(c)) || PROJECT_COLORS[this.config.projects.length % PROJECT_COLORS.length],
109
134
  addedAt: Date.now(),
110
135
  };
@@ -113,6 +138,14 @@ export class Store {
113
138
  return project;
114
139
  }
115
140
 
141
+ // A checkout of the project on `machine`, found or cloned there (services/places.js).
142
+ setCopy(p, machine, dir) {
143
+ if (this.pathAt(p, machine) === dir) return;
144
+ if ((p.machine || null) === (machine || null)) p.path = dir;
145
+ else (p.copies ||= {})[machine || HERE] = dir;
146
+ this.saveConfig();
147
+ }
148
+
116
149
  // Folder-less tasks run in a throwaway directory of their own under ~/.wisp/scratch.
117
150
  scratchDir(id) {
118
151
  const dir = path.join(this.home, 'scratch', id);
@@ -37,6 +37,7 @@ wisp wait <id> # block until it finishes or asks a question; prints t
37
37
  wisp show <id> # current state + latest presentation as markdown
38
38
  wisp ls --state running,waiting
39
39
  wisp reply <id> "also cover the empty-list case" # follow-up; answers a pending question too
40
+ wisp btw <id> "did you touch the migrations?" # side question: answered without interrupting the run
40
41
  wisp stop <id>
41
42
  ```
42
43
 
@@ -412,6 +412,8 @@ pre.code { margin: 0 0 12px; padding: 12px 14px; border-radius: 10px; background
412
412
  .tests-rerun-out { margin-top: 12px; padding: 10px 12px; border-radius: 10px; background: var(--term-bg); color: var(--term-ink); }
413
413
  .tests-rerun-out pre { margin: 0; font: 12px/1.55 var(--mono); white-space: pre-wrap; word-break: break-word; max-height: 260px; overflow-y: auto; color: #b4b4b0; }
414
414
  .rev-tag { display: inline-flex; align-items: center; padding: 1px 6px; border-radius: 5px; font: 500 11px var(--sans); background: var(--bg-2); color: var(--muted); border: 1px solid var(--line); }
415
+ .t-moved { display: inline-flex; align-items: center; gap: 3px; font-size: 11px; color: var(--faint); }
416
+ .t-moved .i { width: 11px; height: 11px; }
415
417
  .cp-sha { font: 500 11px var(--mono); color: var(--faint); padding: 1px 5px; border-radius: 5px; background: var(--surface); border: 1px solid var(--line); }
416
418
  .b-diff.is-reverted .diff-scroll { opacity: 0.55; }
417
419
  .b-diff.is-reverted .path .base, .b-files li.is-reverted .path .base { text-decoration: line-through; color: var(--muted); }
@@ -1,6 +1,6 @@
1
1
  /* ---------- per-item composer ---------- */
2
2
  .composer {
3
- padding: 6px 8px 8px 12px; border-radius: 10px; background: var(--surface-2); border: 1px solid var(--line);
3
+ position: relative; padding: 6px 8px 8px 12px; border-radius: 10px; background: var(--surface-2); border: 1px solid var(--line);
4
4
  transition: box-shadow 0.15s, border-color 0.15s;
5
5
  }
6
6
  .composer:focus-within { background: var(--surface); border-color: color-mix(in srgb, var(--accent) 55%, var(--line-2)); box-shadow: 0 0 0 3px var(--accent-soft); }
@@ -32,6 +32,22 @@
32
32
  .c-queue.on { color: var(--ink); background: var(--bg-2); }
33
33
  .c-done:hover { color: var(--good); }
34
34
 
35
+ /* "/btw …" in the box: a side question. The pickers are for the next follow-up, so they step back. */
36
+ .composer.is-btw .c-mode, .composer.is-btw .c-runner, .composer.is-btw .c-place, .composer.is-btw .c-queue { opacity: 0.35; }
37
+ .c-side { font-weight: 600; color: var(--accent-ink); }
38
+
39
+ /* the / menu (features/slash.js): opens above the box, like the box's own popovers */
40
+ .slash-menu {
41
+ position: absolute; left: 8px; bottom: calc(100% + 6px); z-index: 30; min-width: 280px; max-width: calc(100% - 16px); padding: 5px;
42
+ border-radius: 12px; background: var(--surface); border: 1px solid var(--line); box-shadow: var(--shadow-2); animation: pop 0.18s var(--ease);
43
+ }
44
+ .slash-item { display: flex; align-items: baseline; gap: 8px; width: 100%; padding: 7px 10px; border-radius: 8px; text-align: left; font-size: 13px; color: var(--ink-2); }
45
+ .slash-item b { font: 600 12.5px var(--mono); color: var(--ink); }
46
+ .slash-args { font: 12px var(--mono); color: var(--faint); }
47
+ .slash-hint { margin-left: auto; padding-left: 12px; font-size: 12px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
48
+ .slash-item.on, .slash-item:hover { background: var(--bg-2); }
49
+ .slash-item.on b { color: var(--accent-ink); }
50
+
35
51
  /* attachments tray */
36
52
  .tray { display: flex; flex-wrap: wrap; gap: 8px; padding: 4px 0 8px; }
37
53
  .att {
@@ -59,6 +75,12 @@
59
75
  .c-runner.changed { padding-right: 8px; }
60
76
  .c-runner.changed .rb-logo { margin-right: 4px; }
61
77
  .c-runner:focus-visible { outline: 0; box-shadow: 0 0 0 2px var(--accent-soft), 0 0 0 1px var(--accent); }
78
+ /* where the next follow-up runs: a quiet icon, the computer's name once it's switched */
79
+ .c-place { display: inline-flex; align-items: center; gap: 5px; height: 30px; min-width: 30px; padding: 0 7px; border-radius: 8px; font-size: 12px; color: var(--muted); transition: background 0.15s, color 0.15s; }
80
+ .c-place .i { width: 15px; height: 15px; }
81
+ .c-place:hover, .c-place[aria-expanded='true'], .c-place.changed { background: var(--bg-2); color: var(--ink); }
82
+ .c-place.changed { padding-right: 9px; color: var(--accent); }
83
+ .c-place:focus-visible { outline: 0; box-shadow: 0 0 0 2px var(--accent-soft), 0 0 0 1px var(--accent); }
62
84
  .cr-label { max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500; }
63
85
 
64
86
  /* ---------- task mentions (features/mentions.js) ---------- */
package/web/css/feed.css CHANGED
@@ -26,7 +26,8 @@
26
26
  .toggle-done { cursor: pointer; user-select: none; }
27
27
  .toggle-done:hover { color: var(--ink-2); }
28
28
  .toggle-done .chev { width: 12px; height: 12px; transition: transform 0.2s; }
29
- .f-group.done:not(.collapsed) .chev { transform: rotate(90deg); }
29
+ /* Only the group's own heading: the cards inside have chevrons of their own. */
30
+ .f-group.done:not(.collapsed) > .toggle-done .chev { transform: rotate(90deg); }
30
31
  /* Folded groups are just a row of headings: stack them tight instead of leaving a section gap each. */
31
32
  .f-group.collapsed { margin-bottom: 2px; }
32
33
  .f-group.collapsed h4 { margin: 0; }
@@ -184,8 +185,55 @@
184
185
  .log { max-height: 340px; overflow-y: auto; margin-top: 12px; padding: 10px 12px; border-radius: 10px; background: var(--surface-2); border: 1px solid var(--line); }
185
186
  .w-actions { display: flex; align-items: center; gap: 12px; margin-top: 12px; }
186
187
  .w-queued { font-size: 12px; color: var(--accent-ink); font-weight: 500; }
188
+ /* the /btw tip (features/btw.js): until it's been used once */
189
+ .w-tip { display: inline-flex; align-items: center; gap: 2px; min-width: 0; animation: fade-in 0.4s 0.6s var(--ease) both; }
190
+ .w-tip-go { display: inline-flex; align-items: center; gap: 6px; min-width: 0; font-size: 12px; color: var(--muted); }
191
+ .w-tip-go:hover { color: var(--ink); }
192
+ .w-tip-k { color: var(--faint); }
193
+ .w-tip-go code { flex: none; padding: 1px 5px; border-radius: 5px; background: var(--bg-2); font: 500 11.5px var(--mono); color: var(--ink-2); }
194
+ .w-tip-t { min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
195
+ .w-tip-x { flex: none; display: grid; place-items: center; width: 20px; height: 20px; border-radius: 6px; color: var(--faint); opacity: 0; transition: opacity 0.15s; }
196
+ .w-tip-x .i { width: 11px; height: 11px; }
197
+ .w-tip:hover .w-tip-x, .w-tip-x:focus-visible { opacity: 1; }
198
+ .w-tip-x:hover { background: var(--bg-2); color: var(--ink); }
199
+ @media (hover: none) { .w-tip-x { opacity: 1; } }
200
+ .w-actions > .peek { flex: none; white-space: nowrap; }
201
+ /* No room beside Show all activity and Stop on a phone: the tip gets its own line under them. */
202
+ @media (max-width: 720px) {
203
+ .w-actions:has(.w-tip) { flex-wrap: wrap; row-gap: 8px; }
204
+ .w-tip { order: 1; flex-basis: 100%; }
205
+ }
187
206
  @keyframes tick-in { from { opacity: 0; transform: translateY(-4px); } }
188
207
 
208
+ /* /btw side questions (features/btw.js): plain hairline cards on the turn they were asked during */
209
+ .btw-list { display: grid; gap: 8px; margin-top: 14px; }
210
+ .btw-list:empty { display: none; }
211
+ .btw { position: relative; padding: 10px 40px 11px 14px; border-radius: var(--radius); background: var(--surface); border: 1px solid var(--line); }
212
+ .btw.new { animation: rise 0.35s var(--ease) both; }
213
+ .btw.landed > :is(.btw-a, .btw-err) { animation: fade-in 0.4s var(--ease) both; }
214
+ .btw-q { display: flex; align-items: baseline; gap: 8px; font-size: 13.5px; line-height: 20px; color: var(--ink); }
215
+ .btw-tag {
216
+ flex: none; display: inline-flex; align-items: center; gap: 4px; padding: 1px 6px 1px 4px; border-radius: 5px; cursor: default;
217
+ font: 600 11px/18px var(--sans); color: var(--accent-ink); background: var(--accent-soft); align-self: flex-start; margin-top: 1px;
218
+ }
219
+ .btw-tag .i { width: 12px; height: 12px; }
220
+ .btw-text { min-width: 0; white-space: pre-wrap; overflow-wrap: anywhere; font-weight: 500; }
221
+ .btw-atts { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
222
+ .btw-a { margin-top: 6px; font-size: 13.5px; }
223
+ .btw-a p, .btw-a ul, .btw-a ol, .btw-a pre { margin-bottom: 8px; }
224
+ .btw-wait { display: flex; align-items: center; gap: 8px; margin-top: 7px; font-size: 12.5px; color: var(--muted); }
225
+ .btw-wait .elapsed { margin-left: auto; color: var(--faint); font-variant-numeric: tabular-nums; }
226
+ .btw-dots { display: inline-flex; gap: 3px; }
227
+ .btw-dots i { width: 5px; height: 5px; border-radius: 50%; background: var(--accent); animation: pulse 1.2s ease-in-out infinite; }
228
+ .btw-dots i:nth-child(2) { animation-delay: 0.2s; }
229
+ .btw-dots i:nth-child(3) { animation-delay: 0.4s; }
230
+ .btw-err { display: flex; align-items: baseline; gap: 6px; margin-top: 6px; font-size: 12.5px; color: var(--bad); }
231
+ .btw-err .i { flex: none; width: 13px; height: 13px; translate: 0 2px; }
232
+ .btw-x { position: absolute; top: 6px; right: 6px; width: 26px; height: 26px; border-radius: 7px; color: var(--faint); opacity: 0; transition: opacity 0.15s, background 0.15s, color 0.15s; }
233
+ .btw-x .i { width: 12px; height: 12px; }
234
+ .btw:hover .btw-x, .btw-x:focus-visible, .btw.st-running .btw-x { opacity: 1; }
235
+ @media (hover: none) { .btw-x { opacity: 1; } }
236
+
189
237
  /* ask card (mid-run question) */
190
238
  .ask-card, .b-question {
191
239
  position: relative; padding: 16px 18px; border-radius: var(--radius); margin: 4px 0 14px;
@@ -38,6 +38,10 @@
38
38
  .wm-row:hover .wm-only { opacity: 1; }
39
39
  .wm-only:hover { background: var(--bg-2); color: var(--ink); }
40
40
  .wm-sep { height: 1px; margin: 5px 4px; background: var(--line); }
41
+ .wm-note { margin: 0 10px 6px; max-width: 300px; font-size: 12px; line-height: 1.4; color: var(--muted); }
42
+ .wm-note.warn { color: var(--warn); }
43
+ .place-menu .wm-check { border-radius: 50%; }
44
+ .place-menu .wm-row[disabled] { opacity: 0.45; cursor: default; }
41
45
  .p-chip .p-where { width: 11px; height: 11px; color: var(--muted); }
42
46
  .projects-modal .where { margin-bottom: 12px; }
43
47
  .projects-modal .where .i { width: 13px; height: 13px; color: var(--muted); }
@@ -22,6 +22,21 @@
22
22
  .rr-files { display: flex; flex-wrap: wrap; gap: 6px; padding: 2px 2px 6px; }
23
23
  .rr-file { display: inline-flex; align-items: center; gap: 5px; height: 24px; padding: 0 8px; border-radius: 7px; font-size: 12px; color: var(--ink-2); background: var(--bg-2); }
24
24
  .rr-file .i { width: 12px; height: 12px; color: var(--muted); }
25
+ /* the bar never pushes Rerun out of the box: the harness name gives way first (the logo still says
26
+ which), then the model; on a narrow dialog the mode and place labels fold to their icons */
27
+ .rerun-modal { width: min(660px, 100%); }
28
+ .rr-box { container: rerun / inline-size; }
29
+ .rr-box .l-bar > .runner-btn { flex: 0 1 auto; min-width: 0; overflow: hidden; }
30
+ .rr-box .runner-btn :is(.rb-logo, .rb-e, .chev) { flex: none; }
31
+ .rr-box .runner-btn .rb-h { flex: 0 100 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
32
+ .rr-box .runner-btn .rb-m { flex: 0 1 auto; min-width: 0; }
33
+ .rr-box .where-btn span { max-width: 140px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
34
+ @container rerun (max-width: 520px) {
35
+ .rr-box .l-bar :is(.mode-btn, .where-btn) span, .rr-box .runner-btn .rb-h { display: none; }
36
+ .rr-box .runner-btn .rb-m { margin-left: 0; }
37
+ .rr-box .runner-btn .rb-m::before { display: none; }
38
+ }
39
+ @container rerun (max-width: 400px) { .rr-box .runner-btn :is(.rb-m, .chev) { display: none; } }
25
40
  .overlay:not([hidden]) ~ :is(.picker, .menu) { z-index: 55; }
26
41
  .add-row { display: flex; gap: 8px; }
27
42
  .path-input { position: relative; flex: 1; }
@@ -47,6 +62,10 @@
47
62
  .discover h4 .i { width: 14px; height: 14px; }
48
63
  .discover h4 .muted { font-weight: 400; }
49
64
  .prow.cloning .ppath { color: var(--muted); }
65
+ /* Where a project is checked out: one quiet tag per place. */
66
+ .ppath:has(.pplace) { display: flex; gap: 8px; font-family: inherit; }
67
+ .pplace { display: inline-flex; align-items: center; gap: 4px; }
68
+ .pplace .ic { width: 11px; height: 11px; }
50
69
  .discover h4 .link { margin-left: auto; font-size: 12px; }
51
70
  .prow.cloning .orb.live { margin: 0; }
52
71
  .hint .clonedir { width: 220px; height: 24px; padding: 0 7px; margin: 0 4px; border-radius: 7px; border: 1px solid var(--line-2); background: var(--surface); color: var(--ink); font-size: 12px; outline: 0; }
package/web/css/ship.css CHANGED
@@ -44,6 +44,8 @@
44
44
  .it-meta .m.branch { font: 11px var(--mono); color: var(--ink-2); }
45
45
  .it-meta .m.also { display: inline-flex; align-items: center; gap: 5px; color: var(--ink-2); }
46
46
  .it-meta .m .i { width: 12px; height: 12px; flex: none; color: var(--faint); }
47
+ .it-meta .m.moved-to { color: var(--accent-ink); cursor: pointer; }
48
+ .it-meta .m.moved-to:hover { text-decoration: underline; }
47
49
  .it-meta .m.m-plan, .it-meta .m.m-plan .i { color: var(--accent-ink); }
48
50
  .it-meta .m.m-auto, .it-meta .m.m-auto .i { color: var(--warn); }
49
51
 
@@ -0,0 +1,115 @@
1
+ /* ---------- full transcript (features/transcript.js) ----------
2
+ A timeline under the prompt: a hairline rail with each step's icon on it, the agent's messages as
3
+ prose, and the presentation after the rail ends. */
4
+
5
+ .ghost.tx-toggle.on { color: var(--accent-ink); background: var(--accent-soft); }
6
+
7
+ .tx { display: flex; flex-direction: column; }
8
+ .tx-head { display: flex; align-items: center; gap: 6px; min-height: 22px; margin: -6px 0 8px; font-size: 12px; color: var(--faint); white-space: nowrap; }
9
+ .tx-title { display: inline-flex; align-items: center; gap: 6px; font-weight: 500; color: var(--muted); }
10
+ .tx-count { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
11
+ .tx-title .i { width: 14px; height: 14px; }
12
+ .tx-title + .tx-count::before { content: '·'; margin-right: 6px; }
13
+ .tx-jump { display: inline-flex; align-items: center; gap: 4px; font-size: 12px; }
14
+ .tx-jump .i { width: 12px; height: 12px; transform: rotate(90deg); }
15
+
16
+ .tx-list { position: relative; list-style: none; margin: 0 0 18px; padding: 0; }
17
+ .tx-list::before { content: ''; position: absolute; left: 11.5px; top: 12px; bottom: 15px; width: 1px; background: var(--line-2); }
18
+ /* Mid-run the rail fades out into the working panel: more is coming. */
19
+ .tx.live .tx-list { margin-bottom: 10px; }
20
+ .tx.live .tx-list::before { bottom: -10px; background: linear-gradient(var(--line-2) calc(100% - 28px), transparent); }
21
+
22
+ .tx-step, .tx-fold, .tx-end, .tx-msg { position: relative; padding-left: 28px; }
23
+ .tx-ic {
24
+ position: absolute; left: 2px; top: 5px; width: 20px; height: 20px; display: grid; place-items: center;
25
+ border-radius: 50%; background: var(--surface); color: var(--faint);
26
+ }
27
+ .tx-ic .i { width: 14px; height: 14px; }
28
+
29
+ /* a step: one line, opening to its command and output, its diff, or its error */
30
+ .tx-row {
31
+ display: flex; align-items: center; gap: 10px; width: 100%; min-width: 0; min-height: 30px; padding: 4px 8px;
32
+ border-radius: 7px; font-size: 13px; color: var(--ink-2); text-align: left; outline: 0;
33
+ }
34
+ .tx-row[role='button'] { cursor: pointer; transition: background 0.15s, color 0.15s; }
35
+ .tx-row[role='button']:hover, .tx-row[role='button']:focus-visible, .tx-step.open > .tx-row { background: var(--bg-2); color: var(--ink); }
36
+ .tx-row[role='button']:focus-visible { box-shadow: 0 0 0 2px var(--accent-soft); }
37
+ /* Both give way on a narrow row, the command three times faster. (Shrink factors that sum under 1
38
+ would leave part of the overflow in place.) */
39
+ .tx-label { flex: 0 1 auto; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
40
+ .tx-cmd { flex: 0 3 auto; min-width: 0; font: 12px/1.4 var(--mono); color: var(--faint); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
41
+ .tx-row .nums { margin-left: auto; }
42
+ .tx-t { margin-left: auto; flex: none; font-size: 11px; color: var(--faint); font-variant-numeric: tabular-nums; opacity: 0; transition: opacity 0.15s; }
43
+ .tx-row .nums + .tx-t { margin-left: 0; }
44
+ .tx-step:hover .tx-t, .tx-msg:hover .tx-t, .tx-step.open .tx-t { opacity: 1; }
45
+ .tx-open {
46
+ flex: none; display: grid; place-items: center; width: 22px; height: 22px; margin: -2px -4px -2px 0; border-radius: 6px;
47
+ color: var(--faint); opacity: 0; transition: opacity 0.15s, background 0.15s, color 0.15s;
48
+ }
49
+ .tx-open .i { width: 13px; height: 13px; }
50
+ .tx-step:hover .tx-open, .tx-open:focus-visible { opacity: 1; }
51
+ .tx-open:hover { color: var(--ink); background: var(--surface); }
52
+ .tx-row .chev { flex: none; width: 12px; height: 12px; color: var(--faint); transition: transform 0.2s var(--ease); }
53
+ .tx-step.open > .tx-row .chev { transform: rotate(90deg); }
54
+ .tx-step.loading > .tx-row { opacity: 0.6; cursor: progress; }
55
+
56
+ .tx-step.k-error .tx-ic, .tx-step.k-error .tx-label { color: var(--bad); }
57
+ .tx-step:is(.k-ask, .k-wait) .tx-ic, .tx-step.k-ask .tx-label { color: var(--warn); }
58
+ .tx-step.k-answer .tx-ic { color: var(--good); }
59
+ .tx-step.by-wisp .tx-ic { color: var(--accent); }
60
+ .tx-step.by-wisp .tx-label { color: var(--muted); }
61
+ /* What you were asked and answered, and plan lines, read in full. */
62
+ .tx-step:is(.k-ask, .k-answer, .k-plan, .k-check, .k-background, .by-wisp) .tx-label { white-space: normal; }
63
+
64
+ .tx-more { margin: 2px 0 8px 8px; animation: rise 0.3s var(--ease); }
65
+ .tx-out {
66
+ margin: 0; padding: 10px 12px; max-height: 340px; overflow: auto; border-radius: 8px;
67
+ background: var(--surface-2); border: 1px solid var(--line); font: 12px/1.55 var(--mono); white-space: pre-wrap; word-break: break-word;
68
+ }
69
+ .tx-cmdline, .tx-res { display: block; }
70
+ .tx-cmdline { color: var(--ink); }
71
+ .tx-cmdline b { color: var(--faint); font-weight: 500; }
72
+ .tx-res { color: var(--muted); }
73
+ .tx-cmdline + .tx-res { margin-top: 8px; padding-top: 8px; border-top: 1px dashed var(--line-2); }
74
+ .tx-out.err { color: var(--bad); background: var(--del-bg); border-color: color-mix(in srgb, var(--bad) 22%, var(--line)); }
75
+ .tx-none { margin: 0; font-size: 12.5px; color: var(--muted); }
76
+ .tx-diff .presentation { gap: 8px; }
77
+ .tx-diff .b-diff.bare { border: 1px solid var(--line); border-radius: 8px; background: var(--surface); overflow: hidden; }
78
+
79
+ /* a message: the agent's own words, on a dot of the rail */
80
+ .tx-msg { margin: 8px 0 12px; padding-top: 2px; padding-right: 64px; }
81
+ .tx-dot { position: absolute; left: 8px; top: 9px; width: 8px; height: 8px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 0 4px var(--surface); }
82
+ .tx-msg .prose { padding-left: 8px; font-size: 14px; color: var(--ink); }
83
+ .tx-msg .tx-t { position: absolute; right: 4px; top: 4px; }
84
+ .tx-msg:first-child { margin-top: 0; }
85
+
86
+ /* a long run of steps folds its middle */
87
+ .tx-fold .tx-row { color: var(--muted); cursor: pointer; transition: background 0.15s, color 0.15s; }
88
+ .tx-fold .tx-row:hover { background: var(--bg-2); color: var(--ink); }
89
+
90
+ .tx-end { display: flex; align-items: center; min-height: 30px; padding-left: 36px; font-size: 12.5px; font-weight: 500; color: var(--muted); }
91
+ .tx-end .tx-ic { color: var(--good); }
92
+ .tx-end.st-error .tx-ic { color: var(--bad); }
93
+ .tx-end.st-stopped .tx-ic { color: var(--warn); }
94
+
95
+ .tx-step.new, .tx-msg.new { animation: tick-in 0.45s var(--ease); }
96
+
97
+ /* The working panel under a live transcript: status and Stop on one line, a question under them. */
98
+ .w-panel.tx-panel { padding: 10px 14px; gap: 12px; }
99
+ .tx-panel .w-orb { padding-top: 0; align-self: flex-start; }
100
+ .tx-panel .w-body { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; column-gap: 12px; }
101
+ .tx-panel .w-status { grid-column: 1; grid-row: 1; flex-wrap: wrap; row-gap: 2px; margin: 0; }
102
+ .tx-panel .w-status > * { white-space: nowrap; }
103
+ .tx-panel .w-actions { grid-column: 2; grid-row: 1; margin: 0; }
104
+ .tx-panel .w-body > :not(.w-status, .w-actions) { grid-column: 1 / -1; }
105
+ .tx-panel .ask-slot:not(:empty) { margin-top: 10px; }
106
+
107
+ @media (max-width: 720px) {
108
+ /* The /btw tip doesn't fit beside the status: the row with it and Stop goes under it. */
109
+ .tx-panel .w-body:has(.w-tip) { display: block; }
110
+ .tx-panel .w-body:has(.w-tip) .w-actions { margin-top: 8px; }
111
+ .tx-title { display: none; }
112
+ .tx-cmd { display: none; }
113
+ .tx-msg { padding-right: 8px; }
114
+ .tx-msg .tx-t { display: none; }
115
+ }
package/web/index.html CHANGED
@@ -22,6 +22,7 @@
22
22
  <link rel="stylesheet" href="/css/automations.css">
23
23
  <link rel="stylesheet" href="/css/subagents.css">
24
24
  <link rel="stylesheet" href="/css/auto.css">
25
+ <link rel="stylesheet" href="/css/transcript.css">
25
26
  </head>
26
27
  <body>
27
28
  <svg width="0" height="0" style="position:absolute" aria-hidden="true">
@@ -66,6 +67,7 @@
66
67
  <symbol id="i-stats" viewBox="0 0 24 24"><path d="M5 19V11M12 19V5M19 19v-6" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></symbol>
67
68
  <symbol id="i-rerun" viewBox="0 0 24 24"><path d="M19.5 12a7.5 7.5 0 11-1.8-4.9M20 4v4h-4" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></symbol>
68
69
  <symbol id="i-replay" viewBox="0 0 24 24"><path d="M4.5 12a7.5 7.5 0 101.8-4.9M4 4v4h4" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></symbol>
70
+ <symbol id="i-transcript" viewBox="0 0 24 24"><path d="M6 7.5v9" stroke="currentColor" stroke-width="1.3" stroke-dasharray="1.6 1.8" stroke-linecap="round"/><circle cx="6" cy="5.5" r="1.9" fill="currentColor"/><circle cx="6" cy="12" r="1.9" fill="currentColor"/><circle cx="6" cy="18.5" r="1.9" fill="currentColor"/><path d="M11 5.5h9M11 12h6.5M11 18.5h8" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></symbol>
69
71
  <symbol id="i-text" viewBox="0 0 24 24"><path d="M5 7h14M5 12h14M5 17h9" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></symbol>
70
72
  <symbol id="i-back" viewBox="0 0 24 24"><path d="M15 6l-6 6 6 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></symbol>
71
73
  <symbol id="i-update" viewBox="0 0 24 24"><path d="M12 16V4m-5 5l5-5 5 5M5 20h14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></symbol>
@@ -30,13 +30,14 @@ export const S = {
30
30
  drafts: new Map(),
31
31
  replyModes: new Map(), // session id → the mode its next follow-up switches to
32
32
  replyRunners: new Map(), // session id → { agent, model, effort } its next follow-up switches to
33
+ replyPlaces: new Map(), // session id → the place ('' = this computer) its next follow-up moves it to
33
34
  };
34
35
  export const saveOpen = () => save('wisp.open', [...S.open]);
35
36
  export const setAgents = (list) => ((S.agents = list), save('wisp.agents', list));
36
37
 
37
38
  // Where the launcher sends a prompt: '' = this computer, else machine ids. Several run in parallel,
38
- // each in the project picked for it (launch.picks[place]), and show up as one task. A run can
39
- // work in more of that place's projects too (launch.also[place]): one agent, several folders.
39
+ // all in the project picked (launch.picks['']; any project runs on any place), and show up as one
40
+ // task. A run can work in more projects too (launch.also['']): one agent, several folders.
40
41
  export const launch = {
41
42
  project: load('wisp.launchProject', null), // the project picked in the first place
42
43
  where: load('wisp.launchWhere', ['']),