pi-deck 0.1.5 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -95,6 +95,41 @@ The script will: check for a clean working tree on `main`, bump the version in a
95
95
 
96
96
  The published package includes a bundled server (`dist/server.js`) and the pre-built client SPA (`packages/client/dist/`). Workspace dependencies are inlined by esbuild; only runtime dependencies (`express`, `better-sqlite3`, etc.) are installed by npm.
97
97
 
98
+ ## Job Management
99
+
100
+ Pi-deck integrates with Pi's job management system. Jobs are markdown files with YAML frontmatter that track work through phases (backlog → planning → ready → executing → review → complete).
101
+
102
+ ### Configurable Job Locations
103
+
104
+ By default, jobs are stored in two locations:
105
+ - `~/.pi/agent/jobs/<workspace-name>/` (primary)
106
+ - `<workspace>/.pi/jobs/` (local)
107
+
108
+ You can configure custom job directories by creating a `.pi/jobs.json` file in your workspace root:
109
+
110
+ ```json
111
+ {
112
+ "locations": [
113
+ "~/.pi/agent/jobs/pi-deck",
114
+ ".pi/jobs",
115
+ "./custom-jobs-folder",
116
+ "/absolute/path/to/jobs"
117
+ ],
118
+ "defaultLocation": "~/.pi/agent/jobs/pi-deck"
119
+ }
120
+ ```
121
+
122
+ **Configuration options:**
123
+ - `locations` (required): Array of directory paths to scan for jobs. Supports:
124
+ - Relative paths (resolved from workspace root): `"./jobs"`, `".pi/jobs"`
125
+ - Absolute paths: `"/Users/you/projects/jobs"`
126
+ - Home directory expansion: `"~/.pi/agent/jobs"`
127
+ - `defaultLocation` (optional): Where new jobs are created. If not specified, uses the first location in `locations`.
128
+
129
+ The configuration file is optional — without it, the default two-location behavior is preserved for backward compatibility.
130
+
131
+ See `.pi/jobs.json.example` for a template.
132
+
98
133
  ## Configuration
99
134
 
100
135
  Create a config file at `~/.config/pi-deck/config.json`:
package/dist/server.js CHANGED
@@ -254,13 +254,16 @@ __export(job_service_exports, {
254
254
  extractReviewSection: () => extractReviewSection,
255
255
  getActiveJobStates: () => getActiveJobStates,
256
256
  getJobDirectories: () => getJobDirectories,
257
+ getJobLocations: () => getJobLocations,
257
258
  getNextPhase: () => getNextPhase,
258
259
  getPreviousPhase: () => getPreviousPhase,
260
+ loadJobConfig: () => loadJobConfig,
259
261
  parseJob: () => parseJob,
260
262
  parseJobFrontmatter: () => parseJobFrontmatter,
261
263
  parseTasks: () => parseTasks,
262
264
  promoteJob: () => promoteJob,
263
265
  readJob: () => readJob,
266
+ resolveLocationPath: () => resolveLocationPath,
264
267
  setJobSessionId: () => setJobSessionId,
265
268
  unarchiveJob: () => unarchiveJob,
266
269
  updateJobFrontmatter: () => updateJobFrontmatter,
@@ -271,6 +274,52 @@ import { existsSync as existsSync4, readFileSync as readFileSync3, writeFileSync
271
274
  import { join as join4, basename as basename2, resolve as resolve3, dirname as dirname2 } from "path";
272
275
  import { homedir as homedir4 } from "os";
273
276
  import YAML from "yaml";
277
+ function loadJobConfig(workspacePath) {
278
+ const configPath = join4(workspacePath, ".pi", "jobs.json");
279
+ if (!existsSync4(configPath)) {
280
+ return null;
281
+ }
282
+ try {
283
+ const rawContent = readFileSync3(configPath, "utf-8");
284
+ const parsed = JSON.parse(rawContent);
285
+ if (typeof parsed !== "object" || parsed === null) {
286
+ throw new Error("Configuration must be a JSON object");
287
+ }
288
+ if (!Array.isArray(parsed.locations) || parsed.locations.length === 0) {
289
+ throw new Error('Configuration must have a non-empty "locations" array');
290
+ }
291
+ for (const loc of parsed.locations) {
292
+ if (typeof loc !== "string") {
293
+ throw new Error(`Location must be a string, got ${typeof loc}`);
294
+ }
295
+ }
296
+ if (parsed.defaultLocation !== void 0 && typeof parsed.defaultLocation !== "string") {
297
+ throw new Error(`defaultLocation must be a string, got ${typeof parsed.defaultLocation}`);
298
+ }
299
+ return {
300
+ locations: parsed.locations,
301
+ defaultLocation: parsed.defaultLocation
302
+ };
303
+ } catch (err) {
304
+ if (err instanceof SyntaxError) {
305
+ throw new Error(`Failed to parse ${configPath}: Invalid JSON`);
306
+ }
307
+ throw err;
308
+ }
309
+ }
310
+ function resolveLocationPath(location, workspacePath) {
311
+ if (typeof location !== "string") {
312
+ throw new Error(`Location must be a string, got ${typeof location}`);
313
+ }
314
+ let path = location;
315
+ if (path.startsWith("~/") || path === "~") {
316
+ path = join4(homedir4(), path.slice(1));
317
+ }
318
+ if (!path.startsWith("/")) {
319
+ path = resolve3(workspacePath, path);
320
+ }
321
+ return path;
322
+ }
274
323
  function statusToPhase(status) {
275
324
  switch (status) {
276
325
  case "draft":
@@ -434,6 +483,20 @@ function updateTaskInContent2(content, lineNumber, done) {
434
483
  return lines.join("\n");
435
484
  }
436
485
  function getJobDirectories(workspacePath) {
486
+ try {
487
+ const config2 = loadJobConfig(workspacePath);
488
+ if (config2) {
489
+ return config2.locations.map((loc) => {
490
+ const resolvedPath = resolveLocationPath(loc, workspacePath);
491
+ if (!existsSync4(resolvedPath)) {
492
+ console.warn(`[JobService] Configured job directory does not exist: ${resolvedPath}`);
493
+ }
494
+ return resolvedPath;
495
+ });
496
+ }
497
+ } catch (err) {
498
+ console.warn(`[JobService] Failed to load job configuration: ${err instanceof Error ? err.message : String(err)}`);
499
+ }
437
500
  const workspaceName = basename2(workspacePath);
438
501
  const dirs = [];
439
502
  dirs.push(join4(homedir4(), ".pi", "agent", "jobs", workspaceName));
@@ -490,11 +553,53 @@ function writeJob(jobPath, content) {
490
553
  writeFileSync2(jobPath, content, "utf-8");
491
554
  return parseJob(jobPath, content);
492
555
  }
493
- function createJob(workspacePath, title, description, tags) {
494
- const workspaceName = basename2(workspacePath);
495
- const jobsDir = join4(homedir4(), ".pi", "agent", "jobs", workspaceName);
496
- if (!existsSync4(jobsDir)) {
497
- mkdirSync(jobsDir, { recursive: true });
556
+ function createJob(workspacePath, title, description, tags, location) {
557
+ let config2 = null;
558
+ let jobsDir;
559
+ try {
560
+ config2 = loadJobConfig(workspacePath);
561
+ if (location) {
562
+ if (config2) {
563
+ const resolvedLocation = resolveLocationPath(location, workspacePath);
564
+ const availableLocations = config2.locations.map((loc) => resolveLocationPath(loc, workspacePath));
565
+ if (availableLocations.includes(resolvedLocation)) {
566
+ jobsDir = resolvedLocation;
567
+ } else {
568
+ throw new Error(`Location "${location}" is not in the configured job locations`);
569
+ }
570
+ } else {
571
+ const defaultLocs = [
572
+ join4(homedir4(), ".pi", "agent", "jobs", basename2(workspacePath)),
573
+ join4(workspacePath, ".pi", "jobs")
574
+ ];
575
+ if (defaultLocs.includes(location)) {
576
+ jobsDir = location;
577
+ } else {
578
+ throw new Error(`Location "${location}" is not available (no custom config)`);
579
+ }
580
+ }
581
+ } else if (config2 && config2.defaultLocation) {
582
+ jobsDir = resolveLocationPath(config2.defaultLocation, workspacePath);
583
+ } else if (config2 && config2.locations.length > 0) {
584
+ jobsDir = resolveLocationPath(config2.locations[0], workspacePath);
585
+ } else {
586
+ const workspaceName = basename2(workspacePath);
587
+ jobsDir = join4(homedir4(), ".pi", "agent", "jobs", workspaceName);
588
+ }
589
+ } catch (err) {
590
+ if (err instanceof Error && err.message.includes("not available")) {
591
+ throw err;
592
+ }
593
+ console.warn(`[JobService] Failed to load job config for createJob: ${err instanceof Error ? err.message : String(err)}`);
594
+ const workspaceName = basename2(workspacePath);
595
+ jobsDir = join4(homedir4(), ".pi", "agent", "jobs", workspaceName);
596
+ }
597
+ try {
598
+ if (!existsSync4(jobsDir)) {
599
+ mkdirSync(jobsDir, { recursive: true });
600
+ }
601
+ } catch (err) {
602
+ throw new Error(`Failed to create job directory at ${jobsDir}: ${err instanceof Error ? err.message : String(err)}`);
498
603
  }
499
604
  const now = /* @__PURE__ */ new Date();
500
605
  const dateStr = now.toISOString().slice(0, 10).replace(/-/g, "");
@@ -623,14 +728,22 @@ function discoverArchivedJobs(workspacePath) {
623
728
  }
624
729
  function buildJobSystemContext(workspacePath) {
625
730
  const dirs = getJobDirectories(workspacePath);
731
+ const config2 = loadJobConfig(workspacePath);
626
732
  const primaryDir = dirs[0];
627
733
  const jobs = discoverJobs(workspacePath);
628
734
  const jobLines = jobs.map((j) => ` - [${j.phase}] "${j.title}" \u2192 ${j.path}`).join("\n");
629
735
  const jobListing = jobs.length > 0 ? `
630
736
  Current jobs:
631
737
  ${jobLines}` : "\nNo jobs exist yet.";
738
+ const dirListing = dirs.map((d) => ` - ${d}`).join("\n");
632
739
  return `<job_system>
633
- You have access to a job management system. Jobs are markdown files with YAML frontmatter stored in: ${primaryDir}
740
+ You have access to a job management system. Jobs are markdown files with YAML frontmatter.
741
+
742
+ ## Job Locations
743
+ Jobs are stored in the following directories:
744
+ ${dirListing}
745
+
746
+ ${config2 ? `Configuration loaded from: .pi/jobs.json` : "Using default job locations (no .pi/jobs.json config)"}
634
747
 
635
748
  ## Job File Format
636
749
  \`\`\`markdown
@@ -660,7 +773,7 @@ What needs to be done.
660
773
 
661
774
  ## Managing Jobs
662
775
  - **Create a job**: Write a new .md file to ${primaryDir} with the frontmatter format above. Use filename format: YYYYMMDD-slug.md
663
- - **List jobs**: Read files from ${primaryDir}
776
+ - **List jobs**: Read files from any of the job directories
664
777
  - **Update phase**: Edit the \`phase\` field in frontmatter. Update \`updated\` timestamp.
665
778
  - **Check off tasks**: Change \`- [ ]\` to \`- [x]\` in the job file.
666
779
  - **Complete a job**: Set phase to "complete" and add \`completedAt\` to frontmatter.
@@ -675,7 +788,19 @@ function buildPlanningPrompt(jobPath) {
675
788
  return `<active_job phase="planning">
676
789
  You have a job to plan at: ${jobPath}
677
790
  Read the job file. It contains a title and description.
791
+
792
+ Before creating the plan, you MUST:
793
+ 1. Explore the codebase to understand the current implementation
794
+ 2. Search for relevant files, functions, and existing patterns
795
+ 3. Read documentation and configuration files as needed
796
+ 4. Gather context about the architecture and conventions used
797
+
798
+ Do this research yourself \u2014 DO NOT include research or exploration tasks in the plan. The plan should only contain concrete implementation steps that will be performed after planning is complete.
799
+
678
800
  Your goal is to create a detailed implementation plan. Ask the user clarifying questions if needed, then write a concrete plan with \`- [ ]\` checkbox tasks back into the job file under a "## Plan" section.
801
+
802
+ Plan tasks should be actionable implementation steps only (e.g., "Add function X", "Update file Y"). Do not include research tasks like "review current implementation" \u2014 you should do that during planning, not put it in the plan.
803
+
679
804
  Group tasks under \`### Phase\` headings. Keep tasks concise and actionable (start with a verb).
680
805
  When you're done writing the plan, let the user know so they can review and iterate or mark it as ready.
681
806
  </active_job>`;
@@ -699,6 +824,24 @@ function extractReviewSection(content) {
699
824
  const trimmed = sectionText.trim();
700
825
  return trimmed.length > 0 ? trimmed : null;
701
826
  }
827
+ function getJobLocations(workspacePath) {
828
+ const config2 = loadJobConfig(workspacePath);
829
+ const dirs = getJobDirectories(workspacePath);
830
+ let defaultDir;
831
+ if (config2 && config2.defaultLocation) {
832
+ defaultDir = resolveLocationPath(config2.defaultLocation, workspacePath);
833
+ } else if (config2 && config2.locations.length > 0) {
834
+ defaultDir = resolveLocationPath(config2.locations[0], workspacePath);
835
+ } else {
836
+ defaultDir = dirs[0];
837
+ }
838
+ return dirs.map((dir) => ({
839
+ path: dir,
840
+ isDefault: dir === defaultDir,
841
+ displayName: dir.startsWith(workspacePath) ? `.${dir.slice(workspacePath.length)}` : dir.replace(homedir4(), "~")
842
+ // Replace home directory with ~
843
+ }));
844
+ }
702
845
  function buildReviewPrompt(jobPath) {
703
846
  const content = readFileSync3(jobPath, "utf-8");
704
847
  const reviewSection = extractReviewSection(content);
@@ -1901,7 +2044,9 @@ var PiSession = class extends EventEmitter {
1901
2044
  cacheRead: msg.usage.cacheRead || 0,
1902
2045
  cacheWrite: msg.usage.cacheWrite || 0,
1903
2046
  total: (msg.usage.input || 0) + (msg.usage.output || 0)
1904
- } : void 0
2047
+ } : void 0,
2048
+ stopReason: msg.stopReason,
2049
+ errorMessage: msg.errorMessage
1905
2050
  };
1906
2051
  }
1907
2052
  convertContent(content) {
@@ -6863,6 +7008,28 @@ Please read the plan file and begin working through the tasks.`;
6863
7008
  syncIntegration.setJobs(message.workspaceId, jobs);
6864
7009
  break;
6865
7010
  }
7011
+ case "getJobLocations": {
7012
+ const workspace = workspaceManager2.getWorkspace(message.workspaceId);
7013
+ if (!workspace)
7014
+ break;
7015
+ try {
7016
+ const locations = getJobLocations(workspace.path);
7017
+ const defaultLocation = locations.find((l) => l.isDefault)?.path || locations[0]?.path;
7018
+ send(ws, {
7019
+ type: "jobLocations",
7020
+ workspaceId: message.workspaceId,
7021
+ locations,
7022
+ defaultLocation
7023
+ });
7024
+ } catch (err) {
7025
+ send(ws, {
7026
+ type: "error",
7027
+ message: `Failed to get job locations: ${err instanceof Error ? err.message : "Unknown error"}`,
7028
+ workspaceId: message.workspaceId
7029
+ });
7030
+ }
7031
+ break;
7032
+ }
6866
7033
  case "getJobContent": {
6867
7034
  const workspace = workspaceManager2.getWorkspace(message.workspaceId);
6868
7035
  if (!workspace)
@@ -6890,7 +7057,7 @@ Please read the plan file and begin working through the tasks.`;
6890
7057
  if (!workspace)
6891
7058
  break;
6892
7059
  try {
6893
- const { path: jobPath, job } = createJob(workspace.path, message.title, message.description, message.tags);
7060
+ const { path: jobPath, job } = createJob(workspace.path, message.title, message.description, message.tags, message.location);
6894
7061
  broadcastToWorkspace(message.workspaceId, {
6895
7062
  type: "jobSaved",
6896
7063
  workspaceId: message.workspaceId,