toolcraft 0.0.23 → 0.0.24

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 (147) hide show
  1. package/README.md +2 -2
  2. package/dist/cli.compile-check.js +1 -0
  3. package/dist/cli.d.ts +1 -0
  4. package/dist/cli.js +50 -13
  5. package/dist/error-report.js +32 -3
  6. package/dist/human-in-loop/approval-tasks.d.ts +1 -0
  7. package/dist/human-in-loop/approval-tasks.js +7 -5
  8. package/dist/human-in-loop/approvals-commands.js +51 -8
  9. package/dist/human-in-loop/runner.js +24 -19
  10. package/dist/human-in-loop/state-machine.d.ts +3 -3
  11. package/dist/human-in-loop/state-machine.js +13 -5
  12. package/dist/index.d.ts +5 -0
  13. package/dist/index.js +6 -1
  14. package/dist/mcp-proxy.js +85 -19
  15. package/dist/mcp.compile-check.js +1 -0
  16. package/dist/mcp.d.ts +1 -0
  17. package/dist/mcp.js +50 -8
  18. package/dist/renderer.js +119 -13
  19. package/dist/sdk.compile-check.js +1 -0
  20. package/dist/sdk.d.ts +1 -0
  21. package/dist/sdk.js +56 -11
  22. package/node_modules/@poe-code/agent-defs/dist/registry.d.ts +1 -1
  23. package/node_modules/@poe-code/agent-defs/dist/registry.js +22 -11
  24. package/node_modules/@poe-code/agent-defs/package.json +1 -1
  25. package/node_modules/@poe-code/agent-human-in-loop/dist/providers/osascript-script.js +5 -1
  26. package/node_modules/@poe-code/agent-human-in-loop/dist/providers/osascript.js +1 -1
  27. package/node_modules/@poe-code/agent-human-in-loop/package.json +1 -1
  28. package/node_modules/@poe-code/agent-mcp-config/dist/apply.d.ts +1 -1
  29. package/node_modules/@poe-code/agent-mcp-config/dist/apply.js +41 -92
  30. package/node_modules/@poe-code/agent-mcp-config/dist/configs.js +4 -1
  31. package/node_modules/@poe-code/agent-mcp-config/dist/shapes.d.ts +14 -2
  32. package/node_modules/@poe-code/agent-mcp-config/dist/shapes.js +11 -4
  33. package/node_modules/@poe-code/agent-mcp-config/package.json +1 -1
  34. package/node_modules/@poe-code/config-mutations/dist/execution/apply-mutation.js +200 -22
  35. package/node_modules/@poe-code/config-mutations/dist/execution/path-utils.js +7 -1
  36. package/node_modules/@poe-code/config-mutations/dist/formats/index.js +1 -1
  37. package/node_modules/@poe-code/config-mutations/dist/formats/json.js +11 -7
  38. package/node_modules/@poe-code/config-mutations/dist/formats/object.d.ts +4 -0
  39. package/node_modules/@poe-code/config-mutations/dist/formats/object.js +27 -0
  40. package/node_modules/@poe-code/config-mutations/dist/formats/toml.js +12 -9
  41. package/node_modules/@poe-code/config-mutations/dist/formats/yaml.js +12 -9
  42. package/node_modules/@poe-code/config-mutations/dist/mutations/file-mutation.d.ts +11 -1
  43. package/node_modules/@poe-code/config-mutations/dist/mutations/file-mutation.js +10 -1
  44. package/node_modules/@poe-code/config-mutations/dist/testing/mock-fs.js +25 -1
  45. package/node_modules/@poe-code/config-mutations/dist/types.d.ts +12 -2
  46. package/node_modules/@poe-code/config-mutations/package.json +1 -1
  47. package/node_modules/@poe-code/design-system/dist/acp/components.js +3 -1
  48. package/node_modules/@poe-code/design-system/dist/components/browser.d.ts +1 -1
  49. package/node_modules/@poe-code/design-system/dist/components/browser.js +6 -1
  50. package/node_modules/@poe-code/design-system/dist/components/color.js +9 -8
  51. package/node_modules/@poe-code/design-system/dist/components/command-errors.js +3 -2
  52. package/node_modules/@poe-code/design-system/dist/components/detail-card.d.ts +22 -0
  53. package/node_modules/@poe-code/design-system/dist/components/detail-card.js +69 -0
  54. package/node_modules/@poe-code/design-system/dist/components/help-formatter.js +88 -11
  55. package/node_modules/@poe-code/design-system/dist/components/index.d.ts +1 -1
  56. package/node_modules/@poe-code/design-system/dist/components/index.js +1 -1
  57. package/node_modules/@poe-code/design-system/dist/components/table.d.ts +2 -0
  58. package/node_modules/@poe-code/design-system/dist/components/table.js +82 -5
  59. package/node_modules/@poe-code/design-system/dist/components/template.d.ts +4 -0
  60. package/node_modules/@poe-code/design-system/dist/components/template.js +198 -32
  61. package/node_modules/@poe-code/design-system/dist/components/text.js +29 -5
  62. package/node_modules/@poe-code/design-system/dist/dashboard/ansi.d.ts +2 -2
  63. package/node_modules/@poe-code/design-system/dist/dashboard/ansi.js +77 -32
  64. package/node_modules/@poe-code/design-system/dist/dashboard/buffer.js +28 -5
  65. package/node_modules/@poe-code/design-system/dist/dashboard/components/output-pane.js +45 -28
  66. package/node_modules/@poe-code/design-system/dist/dashboard/terminal-width.d.ts +4 -0
  67. package/node_modules/@poe-code/design-system/dist/dashboard/terminal-width.js +71 -0
  68. package/node_modules/@poe-code/design-system/dist/dashboard/types.d.ts +1 -0
  69. package/node_modules/@poe-code/design-system/dist/explorer/events.d.ts +6 -0
  70. package/node_modules/@poe-code/design-system/dist/explorer/reducer.js +32 -10
  71. package/node_modules/@poe-code/design-system/dist/explorer/render/detail.js +3 -0
  72. package/node_modules/@poe-code/design-system/dist/explorer/runtime.js +57 -6
  73. package/node_modules/@poe-code/design-system/dist/explorer/state.d.ts +1 -0
  74. package/node_modules/@poe-code/design-system/dist/explorer/state.js +12 -15
  75. package/node_modules/@poe-code/design-system/dist/index.d.ts +3 -1
  76. package/node_modules/@poe-code/design-system/dist/index.js +2 -1
  77. package/node_modules/@poe-code/design-system/dist/prompts/primitives/intro.js +2 -1
  78. package/node_modules/@poe-code/design-system/dist/prompts/primitives/log.js +8 -5
  79. package/node_modules/@poe-code/design-system/dist/prompts/primitives/note.js +1 -1
  80. package/node_modules/@poe-code/design-system/dist/static/menu.js +8 -2
  81. package/node_modules/@poe-code/design-system/dist/static/spinner.js +10 -4
  82. package/node_modules/@poe-code/design-system/dist/terminal-markdown/parser/frontmatter.js +9 -2
  83. package/node_modules/@poe-code/design-system/dist/terminal-markdown/renderer.js +19 -2
  84. package/node_modules/@poe-code/design-system/package.json +2 -1
  85. package/node_modules/@poe-code/process-runner/dist/docker/docker-execution-env.js +244 -110
  86. package/node_modules/@poe-code/process-runner/dist/docker/docker-runner.js +16 -4
  87. package/node_modules/@poe-code/process-runner/dist/host/host-execution-env.js +3 -2
  88. package/node_modules/@poe-code/process-runner/dist/host/host-runner.js +16 -1
  89. package/node_modules/@poe-code/process-runner/dist/index.d.ts +1 -0
  90. package/node_modules/@poe-code/process-runner/dist/index.js +1 -0
  91. package/node_modules/@poe-code/process-runner/dist/testing/mock-runner.js +30 -8
  92. package/node_modules/@poe-code/process-runner/dist/types.d.ts +3 -0
  93. package/node_modules/@poe-code/process-runner/dist/workspace-transfer.d.ts +57 -0
  94. package/node_modules/@poe-code/process-runner/dist/workspace-transfer.js +484 -0
  95. package/node_modules/@poe-code/process-runner/package.json +1 -1
  96. package/node_modules/@poe-code/task-list/README.md +0 -2
  97. package/node_modules/@poe-code/task-list/dist/backends/gh-issues-client.js +3 -0
  98. package/node_modules/@poe-code/task-list/dist/backends/gh-issues-sync.js +89 -59
  99. package/node_modules/@poe-code/task-list/dist/backends/gh-issues.d.ts +9 -3
  100. package/node_modules/@poe-code/task-list/dist/backends/gh-issues.js +460 -99
  101. package/node_modules/@poe-code/task-list/dist/backends/markdown-dir.js +156 -154
  102. package/node_modules/@poe-code/task-list/dist/backends/utils.d.ts +2 -0
  103. package/node_modules/@poe-code/task-list/dist/backends/utils.js +79 -0
  104. package/node_modules/@poe-code/task-list/dist/backends/yaml-file.js +120 -132
  105. package/node_modules/@poe-code/task-list/dist/index.d.ts +3 -1
  106. package/node_modules/@poe-code/task-list/dist/index.js +2 -0
  107. package/node_modules/@poe-code/task-list/dist/move.d.ts +2 -0
  108. package/node_modules/@poe-code/task-list/dist/move.js +215 -0
  109. package/node_modules/@poe-code/task-list/dist/open.js +3 -4
  110. package/node_modules/@poe-code/task-list/dist/state-machine.js +3 -1
  111. package/node_modules/@poe-code/task-list/dist/state.js +9 -0
  112. package/node_modules/@poe-code/task-list/dist/types.d.ts +48 -13
  113. package/node_modules/@poe-code/task-list/package.json +1 -2
  114. package/node_modules/auth-store/dist/create-secret-store.js +4 -1
  115. package/node_modules/auth-store/dist/encrypted-file-store.d.ts +7 -0
  116. package/node_modules/auth-store/dist/encrypted-file-store.js +69 -7
  117. package/node_modules/auth-store/dist/index.d.ts +1 -1
  118. package/node_modules/auth-store/dist/keychain-store.d.ts +4 -1
  119. package/node_modules/auth-store/dist/keychain-store.js +18 -16
  120. package/node_modules/auth-store/dist/provider-store.d.ts +5 -1
  121. package/node_modules/auth-store/dist/provider-store.js +55 -7
  122. package/node_modules/auth-store/dist/types.d.ts +3 -1
  123. package/node_modules/auth-store/package.json +2 -1
  124. package/node_modules/mcp-oauth/dist/client/default-oauth-client-provider.js +46 -15
  125. package/node_modules/mcp-oauth/dist/client/loopback-authorization.js +49 -12
  126. package/node_modules/mcp-oauth/dist/client/token-endpoint.js +6 -1
  127. package/node_modules/mcp-oauth/dist/server/jwks-token-verifier.js +1 -1
  128. package/node_modules/mcp-oauth/package.json +1 -0
  129. package/node_modules/tiny-mcp-client/.turbo/turbo-build.log +1 -1
  130. package/node_modules/tiny-mcp-client/dist/internal.d.ts +8 -4
  131. package/node_modules/tiny-mcp-client/dist/internal.js +237 -67
  132. package/node_modules/tiny-mcp-client/dist/oauth-discovery.d.ts +1 -1
  133. package/node_modules/tiny-mcp-client/dist/oauth-discovery.js +4 -7
  134. package/node_modules/tiny-mcp-client/package.json +2 -1
  135. package/node_modules/tiny-mcp-client/src/http-oauth.integration.test.ts +1 -1
  136. package/node_modules/tiny-mcp-client/src/http-oauth.test.ts +46 -0
  137. package/node_modules/tiny-mcp-client/src/internal.ts +279 -77
  138. package/node_modules/tiny-mcp-client/src/mcp-client-tiny-stdio-test-server-tools.test.ts +1 -1
  139. package/node_modules/tiny-mcp-client/src/oauth-discovery.ts +5 -10
  140. package/node_modules/tiny-mcp-client/src/transports.test.ts +588 -6
  141. package/package.json +10 -12
  142. package/node_modules/@poe-code/file-lock/README.md +0 -52
  143. package/node_modules/@poe-code/file-lock/dist/index.d.ts +0 -1
  144. package/node_modules/@poe-code/file-lock/dist/index.js +0 -1
  145. package/node_modules/@poe-code/file-lock/dist/lock.d.ts +0 -27
  146. package/node_modules/@poe-code/file-lock/dist/lock.js +0 -203
  147. package/node_modules/@poe-code/file-lock/package.json +0 -23
@@ -1,17 +1,15 @@
1
1
  import path from "node:path";
2
- import { acquireFileLock } from "@poe-code/file-lock";
3
2
  import { parseDocument, stringify } from "yaml";
4
3
  import taskSchema from "../schema/task.schema.json" with { type: "json" };
5
4
  import { eventsFromState, findEvent } from "../state-machine.js";
6
5
  import { resolveStateMachine } from "../state.js";
7
6
  import { AnchorNotFoundError, InvalidTransitionError, MalformedTaskError, OrderMismatchError, TaskAlreadyExistsError, TaskNotFoundError } from "../types.js";
8
- import { applyOrder, hasErrorCode, isRecord, sortStrings, statIfExists, validateTaskId, writeAtomically } from "./utils.js";
7
+ import { applyOrder, hasErrorCode, isRecord, rejectSymbolicLinkComponents, sortStrings, statIfExists, validateTaskId, withFileLock, writeAtomically } from "./utils.js";
9
8
  const ARCHIVE_DIRECTORY_NAME = "archive";
10
9
  const MARKDOWN_EXTENSION = ".md";
11
10
  const TASK_KIND = "task";
12
11
  const TASK_VERSION = 1;
13
12
  const TASK_SCHEMA_ID = taskSchema.$id;
14
- const ORDER_LOCK_FILENAME = ".order.lock";
15
13
  const MIN_PREFIX_WIDTH = 2;
16
14
  const RESERVED_FRONTMATTER_KEYS = new Set([
17
15
  "$schema",
@@ -69,9 +67,6 @@ function isMarkdownFile(entryName) {
69
67
  function isHiddenEntry(entryName) {
70
68
  return entryName.startsWith(".");
71
69
  }
72
- function isLockFile(entryName) {
73
- return entryName.endsWith(".lock");
74
- }
75
70
  function isValidTaskIdShape(id) {
76
71
  return (id.length > 0 &&
77
72
  !id.startsWith(".") &&
@@ -170,12 +165,20 @@ function assertValidTaskRecord(frontmatter, filePath, validStates) {
170
165
  function reservedFrontmatterKeys(mode) {
171
166
  return mode === "passthrough" ? PASSTHROUGH_RESERVED_FRONTMATTER_KEYS : RESERVED_FRONTMATTER_KEYS;
172
167
  }
168
+ function setOwnValue(record, key, value) {
169
+ Object.defineProperty(record, key, {
170
+ value,
171
+ enumerable: true,
172
+ writable: true,
173
+ configurable: true
174
+ });
175
+ }
173
176
  function metadataFromFrontmatter(frontmatter, mode) {
174
177
  const metadata = {};
175
178
  const reservedKeys = reservedFrontmatterKeys(mode);
176
179
  for (const [key, value] of Object.entries(frontmatter)) {
177
180
  if (!reservedKeys.has(key)) {
178
- metadata[key] = value;
181
+ setOwnValue(metadata, key, value);
179
182
  }
180
183
  }
181
184
  return metadata;
@@ -207,6 +210,7 @@ async function readDirectoryNames(fs, directoryPath) {
207
210
  }
208
211
  }
209
212
  async function ensureRootPath(deps) {
213
+ await rejectSymbolicLinkComponents(deps.fs, deps.path);
210
214
  if (deps.create) {
211
215
  await deps.fs.mkdir(deps.path, { recursive: true });
212
216
  return;
@@ -214,6 +218,7 @@ async function ensureRootPath(deps) {
214
218
  await deps.fs.stat(deps.path);
215
219
  }
216
220
  async function readTaskFile(fs, list, id, filePath, validStates, initialState, mode) {
221
+ await rejectSymbolicLinkComponents(fs, filePath);
217
222
  const content = await fs.readFile(filePath, "utf8");
218
223
  const document = splitTaskDocument(content, filePath, mode);
219
224
  const frontmatter = mode === "passthrough" && document.frontmatter.trim().length === 0
@@ -245,7 +250,7 @@ async function readTaskFile(fs, list, id, filePath, validStates, initialState, m
245
250
  async function findActiveTaskFilename(fs, listDirectoryPath, id) {
246
251
  const entries = await readDirectoryNames(fs, listDirectoryPath);
247
252
  for (const entryName of entries) {
248
- if (isHiddenEntry(entryName) || isLockFile(entryName))
253
+ if (isHiddenEntry(entryName))
249
254
  continue;
250
255
  const parsed = parseActiveFilename(entryName);
251
256
  if (parsed?.id === id) {
@@ -256,15 +261,19 @@ async function findActiveTaskFilename(fs, listDirectoryPath, id) {
256
261
  }
257
262
  async function findTaskLocation(fs, rootPath, layout, list, id) {
258
263
  const listDirectoryPath = listPath(rootPath, layout, list);
264
+ await rejectSymbolicLinkComponents(fs, listDirectoryPath);
259
265
  const activeName = await findActiveTaskFilename(fs, listDirectoryPath, id);
260
266
  if (activeName) {
261
267
  const activePath = path.join(listDirectoryPath, activeName);
268
+ await rejectSymbolicLinkComponents(fs, activePath);
262
269
  const activeStat = await statIfExists(fs, activePath);
263
270
  if (activeStat?.isFile()) {
264
271
  return { archived: false, path: activePath };
265
272
  }
266
273
  }
267
274
  const archivedPath = archivedTaskPath(rootPath, layout, list, id);
275
+ await rejectSymbolicLinkComponents(fs, archiveDirectoryPath(rootPath, layout, list));
276
+ await rejectSymbolicLinkComponents(fs, archivedPath);
268
277
  const archivedStat = await statIfExists(fs, archivedPath);
269
278
  if (archivedStat?.isFile()) {
270
279
  return { archived: true, path: archivedPath };
@@ -294,12 +303,12 @@ function createdFrontmatter(defaults, input, initialState, mode) {
294
303
  const reservedKeys = reservedFrontmatterKeys(mode);
295
304
  for (const [key, value] of Object.entries(defaults.metadata)) {
296
305
  if (!reservedKeys.has(key)) {
297
- frontmatter[key] = value;
306
+ setOwnValue(frontmatter, key, value);
298
307
  }
299
308
  }
300
309
  for (const [key, value] of Object.entries(input.metadata ?? {})) {
301
310
  if (!reservedKeys.has(key)) {
302
- frontmatter[key] = value;
311
+ setOwnValue(frontmatter, key, value);
303
312
  }
304
313
  }
305
314
  frontmatter.created = new Date().toISOString();
@@ -323,7 +332,7 @@ function updatedFrontmatter(existingFrontmatter, task, patch, mode) {
323
332
  const reservedKeys = reservedFrontmatterKeys(mode);
324
333
  for (const [key, value] of Object.entries(patch.metadata ?? {})) {
325
334
  if (!reservedKeys.has(key)) {
326
- nextFrontmatter[key] = value;
335
+ setOwnValue(nextFrontmatter, key, value);
327
336
  }
328
337
  }
329
338
  return nextFrontmatter;
@@ -349,7 +358,7 @@ function firedFrontmatter(existingFrontmatter, task, to, mode, metadataPatch) {
349
358
  const reservedKeys = reservedFrontmatterKeys(mode);
350
359
  for (const [key, value] of Object.entries(metadataPatch ?? {})) {
351
360
  if (!reservedKeys.has(key)) {
352
- nextFrontmatter[key] = value;
361
+ setOwnValue(nextFrontmatter, key, value);
353
362
  }
354
363
  }
355
364
  return nextFrontmatter;
@@ -374,15 +383,17 @@ function createTasksView(deps, layout, list) {
374
383
  const stateMachine = resolveStateMachine(deps.stateMachine);
375
384
  const validStates = new Set(stateMachine.states);
376
385
  async function readActiveEntries() {
386
+ await rejectSymbolicLinkComponents(deps.fs, listDirectoryPath);
377
387
  const entries = await readDirectoryNames(deps.fs, listDirectoryPath);
378
388
  const result = [];
379
389
  for (const entryName of entries) {
380
- if (isHiddenEntry(entryName) || isLockFile(entryName))
390
+ if (isHiddenEntry(entryName))
381
391
  continue;
382
392
  const parsed = parseActiveFilename(entryName);
383
393
  if (!parsed)
384
394
  continue;
385
395
  const entryPath = path.join(listDirectoryPath, entryName);
396
+ await rejectSymbolicLinkComponents(deps.fs, entryPath);
386
397
  const entryStat = await statIfExists(deps.fs, entryPath);
387
398
  if (!entryStat?.isFile())
388
399
  continue;
@@ -409,12 +420,14 @@ function createTasksView(deps, layout, list) {
409
420
  }
410
421
  async function readArchivedTasks() {
411
422
  const archivePath = archiveDirectoryPath(deps.path, layout, list);
423
+ await rejectSymbolicLinkComponents(deps.fs, archivePath);
412
424
  const entries = await readDirectoryNames(deps.fs, archivePath);
413
425
  const result = [];
414
426
  for (const entryName of entries) {
415
- if (isHiddenEntry(entryName) || isLockFile(entryName) || !isMarkdownFile(entryName))
427
+ if (isHiddenEntry(entryName) || !isMarkdownFile(entryName))
416
428
  continue;
417
429
  const entryPath = path.join(archivePath, entryName);
430
+ await rejectSymbolicLinkComponents(deps.fs, entryPath);
418
431
  const entryStat = await statIfExists(deps.fs, entryPath);
419
432
  if (!entryStat?.isFile())
420
433
  continue;
@@ -438,12 +451,32 @@ function createTasksView(deps, layout, list) {
438
451
  const fromPath = path.join(listDirectoryPath, entry.filename);
439
452
  const stagingPath = path.join(listDirectoryPath, `${desiredFilename}.staging-${process.pid}-${index}`);
440
453
  const targetPath = path.join(listDirectoryPath, desiredFilename);
441
- await deps.fs.rename(fromPath, stagingPath);
442
- staged.push({ from: stagingPath, to: targetPath });
454
+ try {
455
+ await deps.fs.rename(fromPath, stagingPath);
456
+ staged.push({ original: fromPath, staging: stagingPath, target: targetPath, finalized: false });
457
+ }
458
+ catch (error) {
459
+ for (const stagedEntry of staged.reverse()) {
460
+ await deps.fs.rename(stagedEntry.staging, stagedEntry.original);
461
+ }
462
+ throw error;
463
+ }
464
+ }
465
+ }
466
+ try {
467
+ for (const entry of staged) {
468
+ await deps.fs.rename(entry.staging, entry.target);
469
+ entry.finalized = true;
443
470
  }
444
471
  }
445
- for (const entry of staged) {
446
- await deps.fs.rename(entry.from, entry.to);
472
+ catch (error) {
473
+ for (const entry of staged.filter((stagedEntry) => stagedEntry.finalized).reverse()) {
474
+ await deps.fs.rename(entry.target, entry.staging);
475
+ }
476
+ for (const entry of staged.reverse()) {
477
+ await deps.fs.rename(entry.staging, entry.original);
478
+ }
479
+ throw error;
447
480
  }
448
481
  }
449
482
  async function rewriteListPrefixes(orderedIds) {
@@ -524,37 +557,6 @@ function createTasksView(deps, layout, list) {
524
557
  await renameActiveEntries(entries, desiredOrdersById);
525
558
  }
526
559
  }
527
- async function withListLock(action) {
528
- await deps.fs.mkdir(listDirectoryPath, { recursive: true });
529
- const release = await acquireFileLock(path.join(listDirectoryPath, ORDER_LOCK_FILENAME), {
530
- fs: deps.fs,
531
- staleMs: deps.lockStaleMs,
532
- retries: deps.lockRetries
533
- });
534
- try {
535
- return await action();
536
- }
537
- finally {
538
- await release();
539
- }
540
- }
541
- async function withTaskLock(id, action) {
542
- validateTaskId(id);
543
- return withListLock(action);
544
- }
545
- async function withLocatedTaskLock(location, action) {
546
- const release = await acquireFileLock(location.path, {
547
- fs: deps.fs,
548
- staleMs: deps.lockStaleMs,
549
- retries: deps.lockRetries
550
- });
551
- try {
552
- return await action();
553
- }
554
- finally {
555
- await release();
556
- }
557
- }
558
560
  async function getTaskFile(id) {
559
561
  validateTaskId(id);
560
562
  return readTaskAtLocation(deps.fs, deps.path, layout, list, id, validStates, stateMachine.initial, deps.frontmatterMode);
@@ -599,8 +601,8 @@ function createTasksView(deps, layout, list) {
599
601
  assertCreateDoesNotSetState(input);
600
602
  assertCreateHasId(input);
601
603
  validateTaskId(input.id);
602
- await deps.fs.mkdir(listDirectoryPath, { recursive: true });
603
- return withListLock(async () => {
604
+ await rejectSymbolicLinkComponents(deps.fs, listDirectoryPath);
605
+ return withFileLock(deps.fs, path.join(listDirectoryPath, ".transition.lock"), async () => {
604
606
  const existing = await findTaskLocation(deps.fs, deps.path, layout, list, input.id);
605
607
  if (existing) {
606
608
  throw new TaskAlreadyExistsError(`Task "${list}/${input.id}" already exists.`);
@@ -619,13 +621,12 @@ function createTasksView(deps, layout, list) {
619
621
  },
620
622
  async update(id, patch) {
621
623
  assertUpdateDoesNotSetState(patch);
622
- return withTaskLock(id, async () => {
623
- const existing = await getTaskFile(id);
624
- const nextFrontmatter = updatedFrontmatter(existing.frontmatter, existing.task, patch, deps.frontmatterMode);
625
- const description = patch.description ?? existing.task.description;
626
- await writeAtomically(deps.fs, existing.path, serializeTaskDocument(nextFrontmatter, description));
627
- return createTask(list, id, nextFrontmatter, description, deps.frontmatterMode, existing.path);
628
- });
624
+ validateTaskId(id);
625
+ const existing = await getTaskFile(id);
626
+ const nextFrontmatter = updatedFrontmatter(existing.frontmatter, existing.task, patch, deps.frontmatterMode);
627
+ const description = patch.description ?? existing.task.description;
628
+ await writeAtomically(deps.fs, existing.path, serializeTaskDocument(nextFrontmatter, description));
629
+ return createTask(list, id, nextFrontmatter, description, deps.frontmatterMode, existing.path);
629
630
  },
630
631
  async fire(id, eventName, opts) {
631
632
  const fireTask = async () => {
@@ -645,33 +646,38 @@ function createTasksView(deps, layout, list) {
645
646
  const serializedTask = serializeTaskDocument(nextFrontmatter, existing.task.description);
646
647
  if (event.to === "archived") {
647
648
  const targetPath = archivedTaskPath(deps.path, layout, list, id);
649
+ await rejectSymbolicLinkComponents(deps.fs, archiveDirectoryPath(deps.path, layout, list));
648
650
  const archivedTargetExists = await statIfExists(deps.fs, targetPath);
649
651
  if (archivedTargetExists?.isFile()) {
650
652
  throw new TaskAlreadyExistsError(`Task "${list}/${id}" already exists in archive.`);
651
653
  }
652
- await writeAtomically(deps.fs, existing.path, serializedTask);
653
654
  await deps.fs.mkdir(archiveDirectoryPath(deps.path, layout, list), { recursive: true });
654
- await deps.fs.rename(existing.path, targetPath);
655
+ await writeAtomically(deps.fs, targetPath, serializedTask);
656
+ try {
657
+ await deps.fs.unlink(existing.path);
658
+ }
659
+ catch (error) {
660
+ await deps.fs.unlink(targetPath);
661
+ throw error;
662
+ }
655
663
  const nextTask = createTask(list, id, nextFrontmatter, existing.task.description, deps.frontmatterMode, targetPath);
656
- await event.onEnter?.(nextTask);
657
- return nextTask;
664
+ return { event, nextTask };
658
665
  }
659
666
  await writeAtomically(deps.fs, existing.path, serializedTask);
660
667
  const nextTask = createTask(list, id, nextFrontmatter, existing.task.description, deps.frontmatterMode, existing.path);
661
- await event.onEnter?.(nextTask);
662
- return nextTask;
668
+ return { event, nextTask };
663
669
  };
664
670
  if (stateMachine.events[eventName]?.to === "archived") {
665
671
  validateTaskId(id);
666
- return withListLock(async () => {
667
- const location = await findTaskLocation(deps.fs, deps.path, layout, list, id);
668
- if (!location) {
669
- throw new TaskNotFoundError(`Task "${list}/${id}" not found.`);
670
- }
671
- return withLocatedTaskLock(location, fireTask);
672
- });
672
+ const location = await findTaskLocation(deps.fs, deps.path, layout, list, id);
673
+ if (!location) {
674
+ throw new TaskNotFoundError(`Task "${list}/${id}" not found.`);
675
+ }
673
676
  }
674
- return withTaskLock(id, fireTask);
677
+ validateTaskId(id);
678
+ const { event, nextTask } = await withFileLock(deps.fs, path.join(listDirectoryPath, ".transition.lock"), fireTask);
679
+ await event.onEnter?.(nextTask);
680
+ return nextTask;
675
681
  },
676
682
  async canFire(id, eventName) {
677
683
  const task = (await getTaskFile(id)).task;
@@ -686,58 +692,53 @@ function createTasksView(deps, layout, list) {
686
692
  return eventsFromState(stateMachine, task.state);
687
693
  },
688
694
  async delete(id) {
689
- await withTaskLock(id, async () => {
690
- const location = await findTaskLocation(deps.fs, deps.path, layout, list, id);
691
- if (!location) {
692
- throw new TaskNotFoundError(`Task "${list}/${id}" not found.`);
693
- }
694
- await deps.fs.unlink(location.path);
695
- });
695
+ validateTaskId(id);
696
+ const location = await findTaskLocation(deps.fs, deps.path, layout, list, id);
697
+ if (!location) {
698
+ throw new TaskNotFoundError(`Task "${list}/${id}" not found.`);
699
+ }
700
+ await deps.fs.unlink(location.path);
696
701
  },
697
702
  async move(id, anchor) {
698
703
  validateTaskId(id);
699
- return withListLock(async () => {
700
- const { entries } = await readActiveTasks();
701
- const fromIndex = entries.findIndex((entry) => entry.id === id);
702
- if (fromIndex < 0) {
703
- throw new TaskNotFoundError(`Task "${list}/${id}" not found.`);
704
- }
705
- const ordered = entries.map((entry) => entry.id);
706
- ordered.splice(fromIndex, 1);
707
- let insertIndex;
708
- if ("position" in anchor) {
709
- insertIndex = anchor.position === "top" ? 0 : ordered.length;
710
- }
711
- else {
712
- const anchorId = "before" in anchor ? anchor.before : anchor.after;
713
- const anchorIndex = ordered.indexOf(anchorId);
714
- if (anchorIndex < 0) {
715
- throw new AnchorNotFoundError(anchorId);
716
- }
717
- insertIndex = "before" in anchor ? anchorIndex : anchorIndex + 1;
704
+ const { entries } = await readActiveTasks();
705
+ const fromIndex = entries.findIndex((entry) => entry.id === id);
706
+ if (fromIndex < 0) {
707
+ throw new TaskNotFoundError(`Task "${list}/${id}" not found.`);
708
+ }
709
+ const ordered = entries.map((entry) => entry.id);
710
+ ordered.splice(fromIndex, 1);
711
+ let insertIndex;
712
+ if ("position" in anchor) {
713
+ insertIndex = anchor.position === "top" ? 0 : ordered.length;
714
+ }
715
+ else {
716
+ const anchorId = "before" in anchor ? anchor.before : anchor.after;
717
+ const anchorIndex = ordered.indexOf(anchorId);
718
+ if (anchorIndex < 0) {
719
+ throw new AnchorNotFoundError(anchorId);
718
720
  }
719
- ordered.splice(insertIndex, 0, id);
720
- await rewriteMovedPrefix(id, ordered);
721
- return (await getTaskFile(id)).task;
722
- });
721
+ insertIndex = "before" in anchor ? anchorIndex : anchorIndex + 1;
722
+ }
723
+ ordered.splice(insertIndex, 0, id);
724
+ await rewriteMovedPrefix(id, ordered);
725
+ return (await getTaskFile(id)).task;
723
726
  },
724
727
  async reorder(ids) {
725
728
  for (const id of ids) {
726
729
  validateTaskId(id);
727
730
  }
728
- return withListLock(async () => {
729
- const { entries } = await readActiveTasks();
730
- const currentIds = entries.map((entry) => entry.id);
731
- const currentSet = new Set(currentIds);
732
- const inputSet = new Set(ids);
733
- const missing = currentIds.filter((id) => !inputSet.has(id));
734
- const extra = ids.filter((id) => !currentSet.has(id));
735
- if (missing.length > 0 || extra.length > 0) {
736
- throw new OrderMismatchError({ missing, extra });
737
- }
738
- await rewriteListPrefixes(ids);
739
- return Promise.all(ids.map(async (id) => (await getTaskFile(id)).task));
740
- });
731
+ const { entries } = await readActiveTasks();
732
+ const currentIds = entries.map((entry) => entry.id);
733
+ const currentSet = new Set(currentIds);
734
+ const inputSet = new Set(ids);
735
+ const missing = currentIds.filter((id) => !inputSet.has(id));
736
+ const extra = ids.filter((id) => !currentSet.has(id));
737
+ if (inputSet.size !== ids.length || missing.length > 0 || extra.length > 0) {
738
+ throw new OrderMismatchError({ missing, extra });
739
+ }
740
+ await rewriteListPrefixes(ids);
741
+ return Promise.all(ids.map(async (id) => (await getTaskFile(id)).task));
741
742
  }
742
743
  };
743
744
  }
@@ -763,12 +764,11 @@ export async function markdownDirBackend(deps) {
763
764
  const entries = await readDirectoryNames(deps.fs, deps.path);
764
765
  const result = [];
765
766
  for (const entryName of entries) {
766
- if (entryName === ARCHIVE_DIRECTORY_NAME ||
767
- isHiddenEntry(entryName) ||
768
- isLockFile(entryName)) {
767
+ if (entryName === ARCHIVE_DIRECTORY_NAME || isHiddenEntry(entryName)) {
769
768
  continue;
770
769
  }
771
770
  const entryPath = path.join(deps.path, entryName);
771
+ await rejectSymbolicLinkComponents(deps.fs, entryPath);
772
772
  const entryStat = await statIfExists(deps.fs, entryPath);
773
773
  if (entryStat?.isDirectory()) {
774
774
  result.push(entryName);
@@ -798,44 +798,46 @@ export async function markdownDirBackend(deps) {
798
798
  const file = await readTaskAtLocation(deps.fs, deps.path, layout, sourceListName, id, validStates, stateMachine.initial, deps.frontmatterMode);
799
799
  return file.task;
800
800
  }
801
- const targetExisting = await findTaskLocation(deps.fs, deps.path, layout, targetListName, id);
802
- if (targetExisting) {
803
- throw new TaskAlreadyExistsError(`Task "${targetListName}/${id}" already exists.`);
804
- }
805
- const sourceLocation = await findTaskLocation(deps.fs, deps.path, layout, sourceListName, id);
806
- if (!sourceLocation) {
807
- throw new TaskNotFoundError(`Task "${sourceListName}/${id}" not found.`);
808
- }
809
801
  const targetListDir = listPath(deps.path, layout, targetListName);
810
- await deps.fs.mkdir(targetListDir, { recursive: true });
811
- const targetEntries = await (async () => {
812
- const out = [];
813
- const names = await readDirectoryNames(deps.fs, targetListDir);
814
- for (const entryName of names) {
815
- if (isHiddenEntry(entryName) || isLockFile(entryName))
816
- continue;
817
- const parsed = parseActiveFilename(entryName);
818
- if (!parsed)
819
- continue;
820
- out.push({ id: parsed.id, order: parsed.order, filename: entryName });
802
+ await rejectSymbolicLinkComponents(deps.fs, targetListDir);
803
+ return withFileLock(deps.fs, path.join(targetListDir, ".transition.lock"), async () => {
804
+ const targetExisting = await findTaskLocation(deps.fs, deps.path, layout, targetListName, id);
805
+ if (targetExisting) {
806
+ throw new TaskAlreadyExistsError(`Task "${targetListName}/${id}" already exists.`);
821
807
  }
822
- return out;
823
- })();
824
- if (sourceLocation.archived) {
825
- const archivedTargetDir = archiveDirectoryPath(deps.path, layout, targetListName);
826
- await deps.fs.mkdir(archivedTargetDir, { recursive: true });
827
- const archivedTargetPath = archivedTaskPath(deps.path, layout, targetListName, id);
828
- await deps.fs.rename(sourceLocation.path, archivedTargetPath);
829
- const file = await readTaskFile(deps.fs, targetListName, id, archivedTargetPath, validStates, stateMachine.initial, deps.frontmatterMode);
830
- return file.task;
831
- }
832
- const maxOrder = targetEntries.reduce((max, entry) => (entry.order !== null && entry.order > max ? entry.order : max), 0);
833
- const width = padWidthForCount(targetEntries.length + 1);
834
- const targetFilename = activeTaskFilename(id, maxOrder + 1, width);
835
- const targetPath = path.join(targetListDir, targetFilename);
836
- await deps.fs.rename(sourceLocation.path, targetPath);
837
- const file = await readTaskFile(deps.fs, targetListName, id, targetPath, validStates, stateMachine.initial, deps.frontmatterMode);
838
- return file.task;
808
+ const sourceLocation = await findTaskLocation(deps.fs, deps.path, layout, sourceListName, id);
809
+ if (!sourceLocation) {
810
+ throw new TaskNotFoundError(`Task "${sourceListName}/${id}" not found.`);
811
+ }
812
+ const sourceFile = await readTaskFile(deps.fs, sourceListName, id, sourceLocation.path, validStates, stateMachine.initial, deps.frontmatterMode);
813
+ const targetEntries = await (async () => {
814
+ const out = [];
815
+ const names = await readDirectoryNames(deps.fs, targetListDir);
816
+ for (const entryName of names) {
817
+ if (isHiddenEntry(entryName))
818
+ continue;
819
+ const parsed = parseActiveFilename(entryName);
820
+ if (!parsed)
821
+ continue;
822
+ out.push({ id: parsed.id, order: parsed.order, filename: entryName });
823
+ }
824
+ return out;
825
+ })();
826
+ if (sourceLocation.archived) {
827
+ const archivedTargetDir = archiveDirectoryPath(deps.path, layout, targetListName);
828
+ await rejectSymbolicLinkComponents(deps.fs, archivedTargetDir);
829
+ await deps.fs.mkdir(archivedTargetDir, { recursive: true });
830
+ const archivedTargetPath = archivedTaskPath(deps.path, layout, targetListName, id);
831
+ await deps.fs.rename(sourceLocation.path, archivedTargetPath);
832
+ return createTask(targetListName, id, sourceFile.frontmatter, sourceFile.task.description, deps.frontmatterMode, archivedTargetPath);
833
+ }
834
+ const maxOrder = targetEntries.reduce((max, entry) => (entry.order !== null && entry.order > max ? entry.order : max), 0);
835
+ const width = padWidthForCount(targetEntries.length + 1);
836
+ const targetFilename = activeTaskFilename(id, maxOrder + 1, width);
837
+ const targetPath = path.join(targetListDir, targetFilename);
838
+ await deps.fs.rename(sourceLocation.path, targetPath);
839
+ return createTask(targetListName, id, sourceFile.frontmatter, sourceFile.task.description, deps.frontmatterMode, targetPath);
840
+ });
839
841
  };
840
842
  return {
841
843
  list,
@@ -11,4 +11,6 @@ export declare function sortStrings(values: string[]): string[];
11
11
  export declare function sortTasks(tasks: Task[]): Task[];
12
12
  export declare function validateTaskId(id: string): string;
13
13
  export declare function statIfExists(fs: TaskListFs, filePath: string): Promise<Awaited<ReturnType<TaskListFs["stat"]>> | undefined>;
14
+ export declare function rejectSymbolicLinkComponents(fs: TaskListFs, filePath: string): Promise<void>;
14
15
  export declare function writeAtomically(fs: TaskListFs, filePath: string, content: string): Promise<void>;
16
+ export declare function withFileLock<T>(fs: TaskListFs, lockPath: string, operation: () => Promise<T>): Promise<T>;
@@ -57,6 +57,26 @@ export async function statIfExists(fs, filePath) {
57
57
  throw error;
58
58
  }
59
59
  }
60
+ export async function rejectSymbolicLinkComponents(fs, filePath) {
61
+ const resolvedPath = path.resolve(filePath);
62
+ const rootPath = path.parse(resolvedPath).root;
63
+ const components = resolvedPath.slice(rootPath.length).split(path.sep).filter(Boolean);
64
+ let currentPath = rootPath;
65
+ for (const component of components) {
66
+ currentPath = path.join(currentPath, component);
67
+ try {
68
+ if ((await fs.lstat(currentPath)).isSymbolicLink()) {
69
+ throw new Error(`Path "${filePath}" contains a symbolic link.`);
70
+ }
71
+ }
72
+ catch (error) {
73
+ if (hasErrorCode(error, "ENOENT")) {
74
+ return;
75
+ }
76
+ throw error;
77
+ }
78
+ }
79
+ }
60
80
  export async function writeAtomically(fs, filePath, content) {
61
81
  const tempPath = `${filePath}.tmp-${process.pid}-${tmpFileCounter}`;
62
82
  tmpFileCounter += 1;
@@ -77,3 +97,62 @@ export async function writeAtomically(fs, filePath, content) {
77
97
  throw error;
78
98
  }
79
99
  }
100
+ export async function withFileLock(fs, lockPath, operation) {
101
+ await fs.mkdir(path.dirname(lockPath), { recursive: true });
102
+ for (;;) {
103
+ try {
104
+ await fs.writeFile(lockPath, String(process.pid), { encoding: "utf8", flag: "wx" });
105
+ break;
106
+ }
107
+ catch (error) {
108
+ if (!hasErrorCode(error, "EEXIST")) {
109
+ throw error;
110
+ }
111
+ if (await removeAbandonedLock(fs, lockPath)) {
112
+ continue;
113
+ }
114
+ await Promise.resolve();
115
+ }
116
+ }
117
+ try {
118
+ return await operation();
119
+ }
120
+ finally {
121
+ await fs.unlink(lockPath);
122
+ }
123
+ }
124
+ async function removeAbandonedLock(fs, lockPath) {
125
+ let content;
126
+ try {
127
+ content = await fs.readFile(lockPath, "utf8");
128
+ }
129
+ catch (error) {
130
+ if (hasErrorCode(error, "ENOENT")) {
131
+ return true;
132
+ }
133
+ throw error;
134
+ }
135
+ const owner = Number(content);
136
+ if (!Number.isInteger(owner) || owner <= 0 || isProcessRunning(owner)) {
137
+ return false;
138
+ }
139
+ try {
140
+ await fs.unlink(lockPath);
141
+ return true;
142
+ }
143
+ catch (error) {
144
+ if (hasErrorCode(error, "ENOENT")) {
145
+ return true;
146
+ }
147
+ throw error;
148
+ }
149
+ }
150
+ function isProcessRunning(pid) {
151
+ try {
152
+ process.kill(pid, 0);
153
+ return true;
154
+ }
155
+ catch (error) {
156
+ return !hasErrorCode(error, "ESRCH");
157
+ }
158
+ }