@stevecastaneda/paseo-progress 1.14.2 → 1.14.4

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 (67) hide show
  1. package/README.md +28 -17
  2. package/client/attachments.tsx +122 -28
  3. package/client/blocks.tsx +33 -8
  4. package/client/chat-row-view.tsx +19 -6
  5. package/client/chat-row.ts +13 -2
  6. package/client/dashboard-query.ts +9 -4
  7. package/client/deliverables.tsx +82 -17
  8. package/client/dialog-stack.tsx +104 -42
  9. package/client/directory.ts +1 -3
  10. package/client/errors.ts +6 -1
  11. package/client/history.tsx +233 -79
  12. package/client/i18n.ts +10 -5
  13. package/client/launcher.tsx +57 -15
  14. package/client/locales/en.ts +28 -22
  15. package/client/locales/zh-CN.ts +18 -17
  16. package/client/messages.ts +1 -0
  17. package/client/motion.tsx +38 -14
  18. package/client/panel-opened.ts +3 -1
  19. package/client/panel.tsx +442 -160
  20. package/client/pdf.tsx +18 -4
  21. package/client/pieces.ts +6 -1
  22. package/client/pill-popover.tsx +108 -23
  23. package/client/pills.tsx +35 -12
  24. package/client/preview.tsx +265 -79
  25. package/client/questions.tsx +173 -52
  26. package/client/row.tsx +14 -3
  27. package/client/section-title.tsx +11 -2
  28. package/client/settings.tsx +76 -24
  29. package/client/show-more.tsx +23 -6
  30. package/client/spinner.tsx +11 -7
  31. package/client/status-badge.tsx +9 -7
  32. package/client/tabs.tsx +98 -20
  33. package/client/ticket-dialog.tsx +241 -73
  34. package/client/time-bar.tsx +5 -2
  35. package/client/video.tsx +15 -6
  36. package/client/whats-new.tsx +94 -36
  37. package/client/when.tsx +2 -1
  38. package/client/write-up.tsx +79 -12
  39. package/dist/server/cli.js +199 -101
  40. package/index.client.tsx +28 -13
  41. package/index.server.ts +19 -1
  42. package/package.json +15 -14
  43. package/server/cli.ts +72 -32
  44. package/server/dashboard.ts +9 -13
  45. package/server/launcher.ts +33 -17
  46. package/server/lock.ts +14 -7
  47. package/server/open.ts +32 -98
  48. package/server/progress-file.ts +6 -6
  49. package/server/question-mode.ts +12 -6
  50. package/server/skill.ts +22 -16
  51. package/server/store.ts +26 -41
  52. package/server/system.ts +113 -0
  53. package/server/tool.ts +89 -44
  54. package/server/whats-new.ts +25 -13
  55. package/shared/attachments.ts +40 -8
  56. package/shared/blocks.ts +9 -6
  57. package/shared/dashboard.ts +46 -29
  58. package/shared/events.ts +12 -10
  59. package/shared/markdown.ts +16 -8
  60. package/shared/question-modes.ts +8 -5
  61. package/shared/rpc.ts +9 -3
  62. package/shared/show.ts +20 -8
  63. package/shared/ticket-story.ts +1 -3
  64. package/skills/paseo-progress/SKILL.md +7 -0
  65. package/tsconfig.json +3 -10
  66. package/client/page-opener.ts +0 -9
  67. package/client/page-screen.tsx +0 -97
package/index.client.tsx CHANGED
@@ -4,7 +4,6 @@ import { ProgressPanel } from "./client/panel";
4
4
  import { contributePills, progressPanel } from "./client/pills";
5
5
  import { QuestionSettings } from "./client/settings";
6
6
  import { ProgressLanguageProvider } from "./client/language";
7
- import { addPageScreen } from "./client/page-screen";
8
7
  import { PdfPanel } from "./client/pdf";
9
8
  import { showsPdfs } from "./shared/attachments";
10
9
  import { CHAT_ROW_KIND, CHAT_ROW_VERSION, chatRowSchema, transformChatRow } from "./client/chat-row";
@@ -20,7 +19,11 @@ export default function contribute(client: PluginClientContext) {
20
19
  title: "Progress",
21
20
  icon: "ListChecks",
22
21
  locations: [panel.location],
23
- Component: (props) => <ProgressLanguageProvider><ProgressPanel {...props} openSettings={() => client.openSettings(SETTINGS_ID)} /></ProgressLanguageProvider>,
22
+ Component: (props) => (
23
+ <ProgressLanguageProvider>
24
+ <ProgressPanel {...props} openSettings={() => client.openSettings(SETTINGS_ID)} />
25
+ </ProgressLanguageProvider>
26
+ ),
24
27
  });
25
28
  const stopCommand = client.addCommandCenterItem({
26
29
  id: "open-progress",
@@ -33,32 +36,44 @@ export default function contribute(client: PluginClientContext) {
33
36
  },
34
37
  });
35
38
 
36
- const stopPdf = showsPdfs(Platform.OS) ? client.addFilePanel({
37
- id: "pdf",
38
- title: "PDF",
39
- icon: "BookOpenText",
40
- files: { extensions: ["pdf"] },
41
- Component: (props) => <ProgressLanguageProvider><PdfPanel {...props} /></ProgressLanguageProvider>,
42
- }) : () => {};
43
- const stopPage = addPageScreen(client);
39
+ const stopPdf = showsPdfs(Platform.OS)
40
+ ? client.addFilePanel({
41
+ id: "pdf",
42
+ title: "PDF",
43
+ icon: "BookOpenText",
44
+ files: { extensions: ["pdf"] },
45
+ Component: (props) => (
46
+ <ProgressLanguageProvider>
47
+ <PdfPanel {...props} />
48
+ </ProgressLanguageProvider>
49
+ ),
50
+ })
51
+ : () => {};
44
52
  const stopSettings = client.addSettingsScreen({
45
53
  id: SETTINGS_ID,
46
54
  title: "Settings",
47
55
  icon: "Settings",
48
- Component: (props) => <ProgressLanguageProvider><QuestionSettings {...props} /></ProgressLanguageProvider>,
56
+ Component: (props) => (
57
+ <ProgressLanguageProvider>
58
+ <QuestionSettings {...props} />
59
+ </ProgressLanguageProvider>
60
+ ),
49
61
  });
50
62
  const stopTransformer = client.addTimelineTransformer({ id: "tool-calls", query: { itemType: "tool_call" }, transform: transformChatRow });
51
63
  const stopRenderer = client.addTimelineRenderer({
52
64
  kind: CHAT_ROW_KIND,
53
65
  version: CHAT_ROW_VERSION,
54
66
  schema: chatRowSchema,
55
- Component: (props) => <ProgressLanguageProvider><ChatRowView {...props} /></ProgressLanguageProvider>,
67
+ Component: (props) => (
68
+ <ProgressLanguageProvider>
69
+ <ChatRowView {...props} />
70
+ </ProgressLanguageProvider>
71
+ ),
56
72
  });
57
73
 
58
74
  return () => {
59
75
  void stopTransformer();
60
76
  void stopRenderer();
61
- stopPage();
62
77
  void stopPdf();
63
78
  void stopSettings();
64
79
  void stopPanel();
package/index.server.ts CHANGED
@@ -6,7 +6,25 @@ import { mirrorQuestionMode } from "./server/question-mode";
6
6
  import { handleInstallSkill, handleReadSkill, handleSkillStatus, repairSkill } from "./server/skill";
7
7
  import { attachProgressTools, handleToolsStatus } from "./server/tool";
8
8
  import { handleDismissWhatsNew, handleGetWhatsNew } from "./server/whats-new";
9
- import { canOpenOnHost, dismissWhatsNew, getAttention, getDashboard, getLauncherStatus, getSkillStatus, getToolsStatus, getWhatsNew, installLauncher, installSkill, listFolders, readSkill, markPanelOpened, openDeliverable, previewDeliverable, readDeliverablePiece, setHistoryFolder } from "./shared/rpc";
9
+ import {
10
+ canOpenOnHost,
11
+ dismissWhatsNew,
12
+ getAttention,
13
+ getDashboard,
14
+ getLauncherStatus,
15
+ getSkillStatus,
16
+ getToolsStatus,
17
+ getWhatsNew,
18
+ installLauncher,
19
+ installSkill,
20
+ listFolders,
21
+ readSkill,
22
+ markPanelOpened,
23
+ openDeliverable,
24
+ previewDeliverable,
25
+ readDeliverablePiece,
26
+ setHistoryFolder,
27
+ } from "./shared/rpc";
10
28
  import { displaySettings, questionSettings } from "./shared/settings";
11
29
 
12
30
  export default function contribute(server: PluginServerContext) {
package/package.json CHANGED
@@ -1,7 +1,15 @@
1
1
  {
2
2
  "name": "@stevecastaneda/paseo-progress",
3
- "version": "1.14.2",
3
+ "version": "1.14.4",
4
4
  "description": "A live progress dashboard per worktree for Paseo, drawn from a file agents write with the Progress tools or the paseo-progress command.",
5
+ "keywords": [
6
+ "coding-agents",
7
+ "dashboard",
8
+ "paseo",
9
+ "paseo-plugin",
10
+ "progress"
11
+ ],
12
+ "homepage": "https://github.com/stevecastaneda/paseo-plugins/tree/main/progress#readme",
5
13
  "license": "MIT",
6
14
  "author": "Steve Castaneda",
7
15
  "repository": {
@@ -9,18 +17,6 @@
9
17
  "url": "git+https://github.com/stevecastaneda/paseo-plugins.git",
10
18
  "directory": "progress"
11
19
  },
12
- "homepage": "https://github.com/stevecastaneda/paseo-plugins/tree/main/progress#readme",
13
- "keywords": [
14
- "paseo",
15
- "paseo-plugin",
16
- "progress",
17
- "dashboard",
18
- "coding-agents"
19
- ],
20
- "type": "module",
21
- "engines": {
22
- "node": "^22.18.0 || >=23.6.0"
23
- },
24
20
  "files": [
25
21
  "paseo-plugin.json",
26
22
  "OVERVIEW.md",
@@ -36,6 +32,7 @@
36
32
  "tsconfig.json",
37
33
  "!**/*.test.ts"
38
34
  ],
35
+ "type": "module",
39
36
  "publishConfig": {
40
37
  "access": "public"
41
38
  },
@@ -47,7 +44,8 @@
47
44
  "check:windows": "node scripts/windows-check.mjs",
48
45
  "build:cli": "rm -rf dist && esbuild server/cli.ts --bundle --platform=node --format=esm --target=node22 --outfile=dist/server/cli.js --log-level=warning",
49
46
  "prepack": "npm run build:cli",
50
- "postpack": "rm -rf dist"
47
+ "postpack": "rm -rf dist",
48
+ "security-review": "node ../scripts/security-review.mjs"
51
49
  },
52
50
  "devDependencies": {
53
51
  "@getpaseo/client": "0.14.0",
@@ -60,5 +58,8 @@
60
58
  "react-native": "0.81.5",
61
59
  "typescript": "^5.9.3",
62
60
  "zod": "^4.4.3"
61
+ },
62
+ "engines": {
63
+ "node": "^22.18.0 || >=23.6.0"
63
64
  }
64
65
  }
package/server/cli.ts CHANGED
@@ -36,7 +36,14 @@ interface Command {
36
36
  usage: string;
37
37
  summary: string;
38
38
  options?: Record<string, { type: "string" | "boolean"; multiple?: boolean }>;
39
- run(args: { positionals: string[]; values: Record<string, string | boolean | Array<string | boolean> | undefined>; state: State; cwd: string; root: string; mode: QuestionMode }): { event: EventDraft; message: string };
39
+ run(args: {
40
+ positionals: string[];
41
+ values: Record<string, string | boolean | Array<string | boolean> | undefined>;
42
+ state: State;
43
+ cwd: string;
44
+ root: string;
45
+ mode: QuestionMode;
46
+ }): { event: EventDraft; message: string };
40
47
  }
41
48
 
42
49
  const USAGE_NAME = "paseo-progress";
@@ -91,7 +98,7 @@ const URL_PATTERN = /^[a-z][a-z\d+.-]*:\/\//i;
91
98
  function parseFile(raw: string, cwd: string, root: string) {
92
99
  const isUrl = URL_PATTERN.test(raw);
93
100
  let index = isUrl ? raw.lastIndexOf("=") : raw.indexOf("=");
94
- while (isUrl && index !== -1 && /[?&][^=&#]*$/.test(raw.slice(0, index))) index = raw.lastIndexOf("=", index - 1);
101
+ if (isUrl) while (index !== -1 && /[?&][^=&#]*$/.test(raw.slice(0, index))) index = raw.lastIndexOf("=", index - 1);
95
102
  const target = (index === -1 ? raw : raw.slice(0, index)).trim();
96
103
  const label = index === -1 ? undefined : raw.slice(index + 1).trim() || undefined;
97
104
  if (!target) throw new UsageError(`--file needs a path or URL, got "${raw}".`);
@@ -177,6 +184,10 @@ function addedByOthersText(tickets: Ticket[], dashboard: Dashboard): string {
177
184
  ].join("\n");
178
185
  }
179
186
 
187
+ function shown(value: unknown) {
188
+ return typeof value === "object" ? ((value as FileLink).path ?? (value as FileLink).url) : value;
189
+ }
190
+
180
191
  const commands: Record<string, Command> = {
181
192
  start: {
182
193
  usage: `start "<title>" [--subtitle <text>] [--item-label <word>]`,
@@ -192,7 +203,8 @@ const commands: Record<string, Command> = {
192
203
  },
193
204
  finish: {
194
205
  usage: `finish "<outcome>"`,
195
- summary: "Close the run with a one-line outcome. Every ticket must be done or skipped, no question open, nothing flagged stuck. New work after this needs start.",
206
+ summary:
207
+ "Close the run with a one-line outcome. Every ticket must be done or skipped, no question open, nothing flagged stuck. New work after this needs start.",
196
208
  run({ positionals, state }) {
197
209
  const outcome = required(positionals[0], "outcome");
198
210
  if (!state.dashboard.run) throw new UsageError("No run to finish.");
@@ -203,7 +215,8 @@ const commands: Record<string, Command> = {
203
215
  },
204
216
  "ticket add": {
205
217
  usage: `ticket add "<title>" --estimate <minutes> [--status <status>] [--waits-for <id>] [--source <path or http(s) URL>[=<label>]]`,
206
- summary: "Add a ticket. Prints its id (T01, T02, ...). --waits-for names a ticket it can't start before; that is the order of work, not a blocker. --source is where the ticket is written up, like its spec file or issue link.",
218
+ summary:
219
+ "Add a ticket. Prints its id (T01, T02, ...). --waits-for names a ticket it can't start before; that is the order of work, not a blocker. --source is where the ticket is written up, like its spec file or issue link.",
207
220
  options: { estimate: { type: "string" }, status: { type: "string" }, "waits-for": { type: "string" }, source: { type: "string" } },
208
221
  run({ positionals, values, state, cwd, root }) {
209
222
  const waitsFor = values["waits-for"] === undefined ? undefined : existingTicket(state, stringOption(values["waits-for"])).id;
@@ -221,7 +234,15 @@ const commands: Record<string, Command> = {
221
234
  "ticket update": {
222
235
  usage: `ticket update <id> [--status <status>] [--stage <name>] [--title <text>] [--estimate <minutes>] [--note <text>] [--waits-for <id>] [--source <path or http(s) URL>[=<label>]]`,
223
236
  summary: `Change a ticket. Statuses: ${TICKET_STATUSES.join(", ")}. --stage names the step in progress, like Build or Fixes ("" clears it). --waits-for names a ticket it can't start before ("" clears it); waiting is not stuck, so don't mark it blocked for that. --source is where the ticket is written up ("" clears it).`,
224
- options: { status: { type: "string" }, stage: { type: "string" }, title: { type: "string" }, estimate: { type: "string" }, note: { type: "string" }, "waits-for": { type: "string" }, source: { type: "string" } },
237
+ options: {
238
+ status: { type: "string" },
239
+ stage: { type: "string" },
240
+ title: { type: "string" },
241
+ estimate: { type: "string" },
242
+ note: { type: "string" },
243
+ "waits-for": { type: "string" },
244
+ source: { type: "string" },
245
+ },
225
246
  run({ positionals, values, state, cwd, root }) {
226
247
  const ticket = existingTicket(state, positionals[0]);
227
248
  const rawWaitsFor = values["waits-for"] === undefined ? undefined : String(values["waits-for"]).trim();
@@ -229,7 +250,8 @@ const commands: Record<string, Command> = {
229
250
  if (waitsFor && waitsFor === ticket.id) throw new UsageError(`${ticket.id} can't wait for itself.`);
230
251
  // Refuse a loop (T01 waits for T02 waits for T01): neither could ever start.
231
252
  for (let next = waitsFor ? state.dashboard.tickets.find((entry) => entry.id === waitsFor) : undefined, seen = 0; next?.waitsFor && seen < 1000; seen++) {
232
- if (next.waitsFor === ticket.id) throw new UsageError(`${ticket.id} can't wait for ${waitsFor}: ${next.id} already waits for ${ticket.id}, so neither could start.`);
253
+ if (next.waitsFor === ticket.id)
254
+ throw new UsageError(`${ticket.id} can't wait for ${waitsFor}: ${next.id} already waits for ${ticket.id}, so neither could start.`);
233
255
  next = state.dashboard.tickets.find((entry) => entry.id === next!.waitsFor);
234
256
  }
235
257
  const event = {
@@ -241,11 +263,10 @@ const commands: Record<string, Command> = {
241
263
  stage: stringOption(values.stage),
242
264
  note: stringOption(values.note),
243
265
  waitsFor,
244
- source: values.source === undefined ? undefined : String(values.source).trim() === "" ? "" as const : parseFile(String(values.source), cwd, root),
266
+ source: values.source === undefined ? undefined : String(values.source).trim() === "" ? ("" as const) : parseFile(String(values.source), cwd, root),
245
267
  };
246
268
  const changes = Object.entries(event).filter(([key, value]) => key !== "type" && key !== "id" && value !== undefined);
247
269
  if (!changes.length) throw new UsageError("Nothing to change. Pass --status, --stage, --title, --estimate, --note, --waits-for, or --source.");
248
- const shown = (value: unknown) => (typeof value === "object" ? (value as FileLink).path ?? (value as FileLink).url : value);
249
270
  return { event, message: `Updated ${ticket.id}: ${changes.map(([key, value]) => `${key} ${shown(value)}`).join(", ")}` };
250
271
  },
251
272
  },
@@ -259,7 +280,8 @@ const commands: Record<string, Command> = {
259
280
  },
260
281
  "question ask": {
261
282
  usage: `question ask "<short title>" "<question>" [--option "A=<label> | <consequence>" ...] --recommend <letter or word> [--big "<why, in a few plain words>"] [--background <text>] [--file <path or http(s) URL>[=<label>] ...] [--raised-by <text>] [--ticket <id>]`,
262
- summary: "Ask the user a question. Prints its reference (Q1, Q2, ...) and what to do until the answer, which depends on the question mode the user picks in Paseo's Settings: Stop (stop all work until an answer), Work around (build nothing the question affects, work on the rest), or Keep building (build your recommendation now, change course if the user picks another). --recommend is the option you would pick. Add --big with the reason when the question is big; it only changes what you do in Keep building, where a big question holds like Work around. A question is big when one of these holds, and only then: (1) building your recommendation before the answer would mean something leaves this computer: pushing, publishing, deploying, changing live data, sending messages, spending money; (2) the answer sets something people or programs outside this repo will rely on: a saved data format, a database schema, a public API, command or setting name, or removing or renaming something people already use; (3) most of the tickets or steps still ahead would be built on the answer. Every other question is small, large code changes included. When you can't name the test it meets, it's small. Name the test in the --big reason.",
283
+ summary:
284
+ "Ask the user a question. Prints its reference (Q1, Q2, ...) and what to do until the answer, which depends on the question mode the user picks in Paseo's Settings: Stop (stop all work until an answer), Work around (build nothing the question affects, work on the rest), or Keep building (build your recommendation now, change course if the user picks another). --recommend is the option you would pick. Add --big with the reason when the question is big; it only changes what you do in Keep building, where a big question holds like Work around. A question is big when one of these holds, and only then: (1) building your recommendation before the answer would mean something leaves this computer: pushing, publishing, deploying, changing live data, sending messages, spending money; (2) the answer sets something people or programs outside this repo will rely on: a saved data format, a database schema, a public API, command or setting name, or removing or renaming something people already use; (3) most of the tickets or steps still ahead would be built on the answer. Every other question is small, large code changes included. When you can't name the test it meets, it's small. Name the test in the --big reason.",
263
285
  options: {
264
286
  option: { type: "string", multiple: true },
265
287
  recommend: { type: "string" },
@@ -315,11 +337,19 @@ const commands: Record<string, Command> = {
315
337
  }
316
338
  const asRecommended = choice.toLowerCase() === question.recommended.toLowerCase();
317
339
  const built = question.state === "building";
318
- const next = !built ? "Build it now."
319
- : asRecommended ? "Carry on with it."
320
- : `Undo the work built on your recommendation and build ${choice} before anything else.`;
340
+ const next = !built
341
+ ? "Build it now."
342
+ : asRecommended
343
+ ? "Carry on with it."
344
+ : `Undo the work built on your recommendation and build ${choice} before anything else.`;
321
345
  return {
322
- event: { type: "question.answer", id: question.id, choice, words: stringOption(values.words), changedCourse: built && !asRecommended ? true : undefined },
346
+ event: {
347
+ type: "question.answer",
348
+ id: question.id,
349
+ choice,
350
+ words: stringOption(values.words),
351
+ changedCourse: built && !asRecommended ? true : undefined,
352
+ },
323
353
  message: `Answered ${question.id}: ${choice}${asRecommended ? " (as you recommended)" : ` (not your recommendation, ${question.recommended})`}. ${next}`,
324
354
  };
325
355
  },
@@ -346,12 +376,14 @@ const commands: Record<string, Command> = {
346
376
  files: files.length ? files : undefined,
347
377
  recommend,
348
378
  };
349
- if (!event.question && !event.background && !event.files && !recommend) throw new UsageError("Nothing to change. Pass --recommend, --background, --question, or --file.");
379
+ if (!event.question && !event.background && !event.files && !recommend)
380
+ throw new UsageError("Nothing to change. Pass --recommend, --background, --question, or --file.");
350
381
  if (!recommend) return { event, message: `Updated ${question.id} (${question.title})` };
351
382
  const rules = QUESTION_MODE_RULES[mode];
352
- const next = question.state === "building"
353
- ? `Undo the work built on ${question.recommended} and build ${recommend} instead.`
354
- : `Mode: ${rules.label}. ${rules.askRule({ recommend, big: Boolean(question.big) })}`;
383
+ const next =
384
+ question.state === "building"
385
+ ? `Undo the work built on ${question.recommended} and build ${recommend} instead.`
386
+ : `Mode: ${rules.label}. ${rules.askRule({ recommend, big: Boolean(question.big) })}`;
355
387
  return { event, message: `${question.id} now recommends ${recommend} (was ${question.recommended}). ${next}` };
356
388
  },
357
389
  },
@@ -365,7 +397,7 @@ const commands: Record<string, Command> = {
365
397
  },
366
398
  "deliverable add": {
367
399
  usage: `deliverable add "<title>" <path or http(s) URL> [--ticket <id>] [--kind ${DELIVERABLE_KINDS.join("|")}]`,
368
- summary: "Add something the user can review. Paths are relative to where you run the command. Prints its id (D1, D2, ...).",
400
+ summary: `Add something the user can review. Paths are relative to where you run the command. Prints its id (D1, D2, ...). An HTML deliverable opens in Paseo's sandboxed preview, which blocks everything from the web, so put its CSS in the page and embed images as data URIs. In the preview, storage APIs and navigator.clipboard throw an error. The alert, confirm, prompt and window.open calls and form submits do nothing. One syntax error stops a whole script.`,
369
401
  options: { ticket: { type: "string" }, kind: { type: "string" } },
370
402
  run({ positionals, values, state, cwd, root }) {
371
403
  const title = required(positionals[0], "deliverable title");
@@ -478,20 +510,21 @@ const commands: Record<string, Command> = {
478
510
  function rootRelative(target: string, cwd: string, root: string): string {
479
511
  const absolute = resolve(cwd, target);
480
512
  const inside = relative(root, absolute);
481
- const suffix = /[\/]$/.test(target) || statSync(absolute, { throwIfNoEntry: false })?.isDirectory() ? "/" : "";
513
+ const suffix = target.endsWith("/") || statSync(absolute, { throwIfNoEntry: false })?.isDirectory() ? "/" : "";
482
514
  if (!inside) return "./";
483
515
  return outside(inside) ? absolute + suffix : inside + suffix;
484
516
  }
485
517
 
486
518
  function guessKind(target: string, isUrl: boolean, cwd: string): (typeof DELIVERABLE_KINDS)[number] {
487
519
  if (isUrl) return "link";
488
- if (/[\/]$/.test(target) || statSync(resolve(cwd, target), { throwIfNoEntry: false })?.isDirectory()) return "folder";
520
+ if (target.endsWith("/") || statSync(resolve(cwd, target), { throwIfNoEntry: false })?.isDirectory()) return "folder";
489
521
  if (imageMimeType(target)) return "screenshot";
490
522
  return "file";
491
523
  }
492
524
 
493
525
  const SHOW_SUMMARY = "Print the dashboard as text: what the user sees in the Progress panel.";
494
- const INSTALL_SUMMARY = "Write the paseo-progress launcher (default ~/.local/bin/paseo-progress). It runs this command from whichever copy of the plugin Paseo is running.";
526
+ const INSTALL_SUMMARY =
527
+ "Write the paseo-progress launcher (default ~/.local/bin/paseo-progress). It runs this command from whichever copy of the plugin Paseo is running.";
495
528
  const DEFAULT_LAUNCHER = "~/.local/bin/paseo-progress";
496
529
 
497
530
  // The agent tools describe themselves with the summaries agents see in --help.
@@ -539,7 +572,7 @@ export async function runCli(argv: string[], options: CliOptions): Promise<numbe
539
572
  return 0;
540
573
  }
541
574
  const target = resolve(options.cwd, (argv[1] ?? DEFAULT_LAUNCHER).replace(/^~(?=$|\/)/, homedir()));
542
- out(await installLauncher(target) ? `Installed the launcher at ${target}` : `The launcher at ${target} is already current`);
575
+ out((await installLauncher(target)) ? `Installed the launcher at ${target}` : `The launcher at ${target} is already current`);
543
576
  return 0;
544
577
  }
545
578
  const twoWord = commands[`${first} ${second}`];
@@ -563,15 +596,20 @@ export async function runCli(argv: string[], options: CliOptions): Promise<numbe
563
596
  const root = findRoot(options.cwd);
564
597
  const store = await findStore(root);
565
598
  const mode = await readQuestionMode();
566
- const message = await appendProgress(store.directory, now, (state) => {
567
- const finished = state.dashboard.run?.finished;
568
- if (finished && command !== commands.start) {
569
- throw new UsageError(`The run "${state.dashboard.run!.title}" is finished. Start new work with: ${USAGE_NAME} start "<title>"`);
570
- }
571
- const { event, message } = command.run({ positionals: parsed.positionals, values: parsed.values, state, cwd: options.cwd, root, mode });
572
- const added = command === commands.start ? "" : addedByOthersText(addedByOthers(state.events, state.dashboard, options.agentId), state.dashboard);
573
- return { event: { ...event, by: options.agentId }, result: `${message}${added}` };
574
- }, (text) => saveRunCopy(root, text));
599
+ const message = await appendProgress(
600
+ store.directory,
601
+ now,
602
+ (state) => {
603
+ const finished = state.dashboard.run?.finished;
604
+ if (finished && command !== commands.start) {
605
+ throw new UsageError(`The run "${state.dashboard.run!.title}" is finished. Start new work with: ${USAGE_NAME} start "<title>"`);
606
+ }
607
+ const { event, message: summary } = command.run({ positionals: parsed.positionals, values: parsed.values, state, cwd: options.cwd, root, mode });
608
+ const added = command === commands.start ? "" : addedByOthersText(addedByOthers(state.events, state.dashboard, options.agentId), state.dashboard);
609
+ return { event: { ...event, by: options.agentId }, result: `${summary}${added}` };
610
+ },
611
+ (text) => saveRunCopy(root, text),
612
+ );
575
613
  out(message);
576
614
  return 0;
577
615
  } catch (error) {
@@ -594,7 +632,9 @@ function isMain(): boolean {
594
632
 
595
633
  if (isMain()) {
596
634
  runCli(process.argv.slice(2), { cwd: process.cwd(), agentId: process.env.PASEO_AGENT_ID || undefined }).then(
597
- (code) => { process.exitCode = code; },
635
+ (code) => {
636
+ process.exitCode = code;
637
+ },
598
638
  (error) => {
599
639
  console.error(error instanceof Error ? error.message : error);
600
640
  process.exitCode = 1;
@@ -10,10 +10,7 @@ import { findStore, historyFolder, inGitRepo, listFolders, setHistoryFolder } fr
10
10
 
11
11
  const versions = new WeakMap<Dashboard, { key: string; version: string }>();
12
12
 
13
- export async function handleMarkPanelOpened(
14
- input: { workspaceId: string; workspaceDirectory: string },
15
- _context: PluginHandlerContext,
16
- ) {
13
+ export async function handleMarkPanelOpened(input: { workspaceId: string; workspaceDirectory: string }, _context: PluginHandlerContext) {
17
14
  await markPanelOpened((await findStore(findRoot(input.workspaceDirectory))).directory);
18
15
  return {};
19
16
  }
@@ -33,7 +30,12 @@ export async function handleGetDashboard(
33
30
  const key = JSON.stringify([result.panelOpened, file, savedTo, inRepo]);
34
31
  let cached = versions.get(result.dashboard);
35
32
  if (!cached || cached.key !== key) {
36
- cached = { key, version: createHash("sha1").update(JSON.stringify(result) + key).digest("base64url") };
33
+ cached = {
34
+ key,
35
+ version: createHash("sha1")
36
+ .update(JSON.stringify(result) + key)
37
+ .digest("base64url"),
38
+ };
37
39
  versions.set(result.dashboard, cached);
38
40
  }
39
41
  const { version } = cached;
@@ -41,10 +43,7 @@ export async function handleGetDashboard(
41
43
  }
42
44
 
43
45
  // Just what a message-box pill needs, so polling every workspace stays small.
44
- export async function handleGetAttention(
45
- input: { workspaceId: string; workspaceDirectory: string },
46
- _context: PluginHandlerContext,
47
- ): Promise<AttentionResult> {
46
+ export async function handleGetAttention(input: { workspaceId: string; workspaceDirectory: string }, _context: PluginHandlerContext): Promise<AttentionResult> {
48
47
  const { configured, dashboard, panelOpened } = await readProgress((await findStore(findRoot(input.workspaceDirectory))).directory);
49
48
  return {
50
49
  configured,
@@ -55,10 +54,7 @@ export async function handleGetAttention(
55
54
  };
56
55
  }
57
56
 
58
- export async function handleListFolders(
59
- input: { workspaceId: string; workspaceDirectory: string; folder: string },
60
- _context: PluginHandlerContext,
61
- ) {
57
+ export async function handleListFolders(input: { workspaceId: string; workspaceDirectory: string; folder: string }, _context: PluginHandlerContext) {
62
58
  return listFolders(findRoot(input.workspaceDirectory), input.folder);
63
59
  }
64
60
 
@@ -1,6 +1,7 @@
1
- import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
1
+ import { readFile } from "node:fs/promises";
2
2
  import { homedir } from "node:os";
3
3
  import { basename, dirname, join, posix, win32 } from "node:path";
4
+ import { writeFileIn } from "./system.ts";
4
5
 
5
6
  export const PLUGIN_ID = "progress";
6
7
 
@@ -8,9 +9,10 @@ export const PLUGIN_ID = "progress";
8
9
  // Paseo names for the agents and plugins it starts, PATH, then the app's
9
10
  // default install. The launcher script below carries the same list.
10
11
  export function paseoCommands(env: NodeJS.ProcessEnv = process.env, platform: NodeJS.Platform = process.platform): string[] {
11
- const installed = platform === "win32"
12
- ? env.LOCALAPPDATA && win32.join(env.LOCALAPPDATA, "Programs", "Paseo", "resources", "bin", "paseo.cmd")
13
- : "/Applications/Paseo.app/Contents/Resources/bin/paseo";
12
+ const installed =
13
+ platform === "win32"
14
+ ? env.LOCALAPPDATA && win32.join(env.LOCALAPPDATA, "Programs", "Paseo", "resources", "bin", "paseo.cmd")
15
+ : "/Applications/Paseo.app/Contents/Resources/bin/paseo";
14
16
  return [env.PASEO_BIN, env.PASEO_CLI, "paseo", installed].filter((cli): cli is string => Boolean(cli));
15
17
  }
16
18
 
@@ -28,7 +30,7 @@ if (major < 22 || (major === 22 && minor < 18)) {
28
30
  // Not require: a package.json with "type": "module" above this folder makes Node load it as ESM.
29
31
  const { spawnSync } = process.getBuiltinModule("node:child_process");
30
32
  const { existsSync } = process.getBuiltinModule("node:fs");
31
- const { join } = process.getBuiltinModule("node:path");
33
+ const { dirname, join } = process.getBuiltinModule("node:path");
32
34
 
33
35
  const windows = process.platform === "win32";
34
36
 
@@ -41,11 +43,14 @@ function paseoCommands() {
41
43
  }
42
44
 
43
45
  // On Windows Paseo's command is paseo.cmd, which Node can start, or find on
44
- // PATH, only through cmd.exe.
46
+ // PATH, only through cmd.exe. cmd.exe looks in the folder it starts in before
47
+ // PATH, so it starts in Node's folder, not the agent's, where a repo could
48
+ // have put its own paseo.cmd.
45
49
  function askPaseo(cli) {
50
+ const options = { cwd: dirname(process.execPath), encoding: "utf8" };
46
51
  return windows
47
- ? spawnSync('"' + cli + '" plugin ls ${PLUGIN_ID} --json', { shell: true, encoding: "utf8" })
48
- : spawnSync(cli, ["plugin", "ls", "${PLUGIN_ID}", "--json"], { encoding: "utf8" });
52
+ ? spawnSync('"' + cli + '" plugin ls ${PLUGIN_ID} --json', { ...options, shell: true })
53
+ : spawnSync(cli, ["plugin", "ls", "${PLUGIN_ID}", "--json"], options);
49
54
  }
50
55
 
51
56
  function pluginDirectory() {
@@ -87,11 +92,20 @@ export type LauncherState = "missing" | "current" | "outdated" | "foreign";
87
92
  type LauncherFile = { path: string; content: string };
88
93
 
89
94
  // PowerShell and cmd run only files with an extension like .cmd, so on
90
- // Windows a .cmd file beside the launcher runs it with node.
95
+ // Windows a .cmd file beside the launcher runs it with Node. It names the Node
96
+ // that installed it: cmd.exe would look for a bare "node" in the agent's
97
+ // folder first, where a repo could have put its own. Paseo Desktop runs
98
+ // plugins on its own app in Node mode, which the variable turns on. A % in the
99
+ // path is doubled so cmd.exe reads it as written.
91
100
  function launcherFiles(path: string, platform: NodeJS.Platform): LauncherFile[] {
92
101
  const files = [{ path, content: launcherScript() }];
93
102
  if (platform === "win32") {
94
- files.push({ path: `${path}.cmd`, content: `@echo off\r\nrem ${MARKER}. Lets PowerShell and cmd run ${basename(path)}.\r\nnode "%~dp0${basename(path)}" %*\r\nexit /b %errorlevel%\r\n` });
103
+ const node = process.execPath.replaceAll("%", "%%");
104
+ const asNode = process.versions.electron ? `set "ELECTRON_RUN_AS_NODE=1"\r\n` : "";
105
+ files.push({
106
+ path: `${path}.cmd`,
107
+ content: `@echo off\r\nrem ${MARKER}. Lets PowerShell and cmd run ${basename(path)}.\r\nsetlocal\r\n${asNode}"${node}" "%~dp0${basename(path)}" %*\r\nexit /b %errorlevel%\r\n`,
108
+ });
95
109
  }
96
110
  return files;
97
111
  }
@@ -118,9 +132,7 @@ export async function installLauncher(path: string, platform: NodeJS.Platform =
118
132
  let wrote = false;
119
133
  for (const file of files) {
120
134
  if (file.state === "current") continue;
121
- await mkdir(dirname(file.path), { recursive: true });
122
- await writeFile(file.path, file.content);
123
- await chmod(file.path, 0o755);
135
+ await writeFileIn(dirname(file.path), basename(file.path), file.content, { executable: true });
124
136
  wrote = true;
125
137
  }
126
138
  return wrote;
@@ -132,12 +144,16 @@ export async function launcherStatus(path = defaultLauncherPath(), pathEnv = pro
132
144
  const files = await fileStates(path, platform);
133
145
  const states = files.map((file) => file.state);
134
146
  const foreign = files.find((file) => file.state === "foreign");
135
- const state: LauncherState = foreign ? "foreign"
136
- : states.every((each) => each === "current") ? "current"
137
- : states.every((each) => each === "missing") ? "missing" : "outdated";
147
+ const state: LauncherState = foreign
148
+ ? "foreign"
149
+ : states.every((each) => each === "current")
150
+ ? "current"
151
+ : states.every((each) => each === "missing")
152
+ ? "missing"
153
+ : "outdated";
138
154
  // Windows paths ignore case and may end in either slash.
139
155
  const paths = platform === "win32" ? win32 : posix;
140
- const folder = (dir: string) => platform === "win32" ? dir.replace(/[\\/]+$/, "").toLowerCase() : dir.replace(/\/+$/, "");
156
+ const folder = (dir: string) => (platform === "win32" ? dir.replace(/[\\/]+$/, "").toLowerCase() : dir.replace(/\/+$/, ""));
141
157
  return {
142
158
  path: foreign?.path ?? path,
143
159
  state,
package/server/lock.ts CHANGED
@@ -1,4 +1,6 @@
1
- import { mkdir, rm, stat } from "node:fs/promises";
1
+ import { stat } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { createFolderIn, removeIn } from "./system.ts";
2
4
 
3
5
  const RETRY_MS = 25;
4
6
  const WAIT_MS = 10_000;
@@ -7,26 +9,31 @@ const STALE_MS = 30_000;
7
9
 
8
10
  // Serializes read-then-append so two commands never hand out the same id or
9
11
  // interleave a line. mkdir is atomic, so only one process creates the folder.
10
- export async function withLock<T>(lockPath: string, work: () => Promise<T>): Promise<T> {
12
+ export async function withLock<T>(folder: string, name: string, work: () => Promise<T>): Promise<T> {
13
+ const lockPath = join(folder, name);
11
14
  const started = Date.now();
12
15
  while (true) {
13
16
  try {
14
- await mkdir(lockPath);
17
+ await createFolderIn(folder, name);
15
18
  break;
16
19
  } catch (error) {
17
20
  if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
18
- const since = await stat(lockPath).then((info) => Date.now() - info.mtimeMs, () => 0);
21
+ const since = await stat(lockPath).then(
22
+ (info) => Date.now() - info.mtimeMs,
23
+ () => 0,
24
+ );
19
25
  if (since > STALE_MS) {
20
- await rm(lockPath, { recursive: true, force: true });
26
+ await removeIn(folder, name, { recursive: true });
21
27
  continue;
22
28
  }
23
- if (Date.now() - started > WAIT_MS) throw new Error(`Timed out waiting for ${lockPath}. If no other progress command is running, delete it.`);
29
+ if (Date.now() - started > WAIT_MS)
30
+ throw new Error(`Timed out waiting for ${lockPath}. If no other progress command is running, delete it.`, { cause: error });
24
31
  await new Promise((resolve) => setTimeout(resolve, RETRY_MS + Math.random() * RETRY_MS));
25
32
  }
26
33
  }
27
34
  try {
28
35
  return await work();
29
36
  } finally {
30
- await rm(lockPath, { recursive: true, force: true });
37
+ await removeIn(folder, name, { recursive: true });
31
38
  }
32
39
  }