create-walle 0.9.21 → 0.9.22

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 (52) hide show
  1. package/README.md +5 -5
  2. package/package.json +2 -2
  3. package/template/claude-task-manager/api-prompts.js +13 -0
  4. package/template/claude-task-manager/api-reviews.js +5 -2
  5. package/template/claude-task-manager/db.js +348 -15
  6. package/template/claude-task-manager/docs/app-update-refresh-protocol.md +69 -0
  7. package/template/claude-task-manager/docs/image-paste-ux.md +3 -0
  8. package/template/claude-task-manager/docs/ipad-web-preview.md +88 -0
  9. package/template/claude-task-manager/git-utils.js +146 -17
  10. package/template/claude-task-manager/lib/auth-rate-limit.js +23 -3
  11. package/template/claude-task-manager/lib/auth-rules.js +3 -0
  12. package/template/claude-task-manager/lib/document-review.js +33 -2
  13. package/template/claude-task-manager/lib/microsoft-dev-tunnel-setup.js +83 -0
  14. package/template/claude-task-manager/lib/mobile-auth-api.js +14 -0
  15. package/template/claude-task-manager/lib/restart-guard.js +68 -0
  16. package/template/claude-task-manager/lib/session-standup.js +36 -13
  17. package/template/claude-task-manager/lib/session-stream.js +11 -4
  18. package/template/claude-task-manager/lib/transport-security.js +50 -0
  19. package/template/claude-task-manager/lib/walle-transcript.js +16 -0
  20. package/template/claude-task-manager/lib/worktree-active-sync.js +6 -3
  21. package/template/claude-task-manager/public/css/reviews.css +10 -0
  22. package/template/claude-task-manager/public/css/setup.css +13 -0
  23. package/template/claude-task-manager/public/css/walle.css +145 -0
  24. package/template/claude-task-manager/public/index.html +539 -44
  25. package/template/claude-task-manager/public/ipad.html +363 -0
  26. package/template/claude-task-manager/public/js/document-review-links.js +196 -0
  27. package/template/claude-task-manager/public/js/message-renderer.js +14 -3
  28. package/template/claude-task-manager/public/js/reviews.js +30 -6
  29. package/template/claude-task-manager/public/js/setup.js +42 -2
  30. package/template/claude-task-manager/public/js/stream-view.js +20 -1
  31. package/template/claude-task-manager/public/js/walle.js +314 -18
  32. package/template/claude-task-manager/public/m/app.css +789 -11
  33. package/template/claude-task-manager/public/m/app.js +1070 -67
  34. package/template/claude-task-manager/public/m/claim.html +9 -2
  35. package/template/claude-task-manager/public/m/index.html +17 -10
  36. package/template/claude-task-manager/public/m/sw.js +1 -1
  37. package/template/claude-task-manager/server.js +365 -95
  38. package/template/claude-task-manager/session-integrity.js +4 -0
  39. package/template/docs/designs/2026-05-17-portkey-gateway-provider-ux.md +86 -35
  40. package/template/package.json +1 -1
  41. package/template/wall-e/api-walle.js +19 -1
  42. package/template/wall-e/brain.js +152 -6
  43. package/template/wall-e/chat.js +85 -0
  44. package/template/wall-e/coding-orchestrator.js +106 -12
  45. package/template/wall-e/http/model-admin.js +131 -0
  46. package/template/wall-e/lib/service-health.js +194 -0
  47. package/template/wall-e/llm/anthropic.js +7 -0
  48. package/template/wall-e/llm/client.js +46 -12
  49. package/template/wall-e/llm/openai.js +17 -2
  50. package/template/wall-e/llm/portkey-sync.js +201 -0
  51. package/template/wall-e/server.js +13 -0
  52. package/template/website/index.html +10 -10
@@ -0,0 +1,88 @@
1
+ # CTM iPad Web Preview
2
+
3
+ Status: implemented for local and staging checks
4
+ Date: 2026-05-19
5
+
6
+ ## Goal
7
+
8
+ Use `/ipad.html` to run the CTM mobile app inside an iPad-sized frame from any CTM server. This is a fast desktop-browser check for the iPad layout before testing on a physical iPad.
9
+
10
+ The preview is intentionally same-origin by default:
11
+
12
+ ```text
13
+ http://localhost:3456/ipad.html
14
+ frames
15
+ http://localhost:3456/m/
16
+ ```
17
+
18
+ When testing a staging/tunnel server, open that server's own preview page:
19
+
20
+ ```text
21
+ https://<staging-host>/ipad.html
22
+ ```
23
+
24
+ That keeps CTM cookies, device tokens, service worker scope, and API calls on the same origin as the mobile app.
25
+
26
+ ## Run A Safe Local Staging Instance
27
+
28
+ Use `bin/dev.sh --refresh` with a separate port and data directory so iPad testing has copied CTM/Wall-E data without mutating the primary CTM state:
29
+
30
+ ```bash
31
+ WALLE_DEV_DIR=/tmp/walle-dev-4456 \
32
+ DEV_CTM_PORT=4456 \
33
+ DEV_WALLE_PORT=4457 \
34
+ bash bin/dev.sh --refresh
35
+ ```
36
+
37
+ Open:
38
+
39
+ ```text
40
+ http://localhost:4456/ipad.html
41
+ ```
42
+
43
+ The source field defaults to `/m/`. Keep it there for normal testing. If the preview loads but has no sessions, restart with `--refresh`; that copies the current CTM and Wall-E databases into the staging data directory.
44
+
45
+ ## Preview Modes
46
+
47
+ `/ipad.html` includes four device presets:
48
+
49
+ - `Test Portrait`: `768 x 1024`, matching the automated tablet regression test.
50
+ - `Test Landscape`: `1024 x 768`, matching the automated split-workspace regression test.
51
+ - `11 Portrait`: `834 x 1194`, close to an 11-inch iPad CSS viewport.
52
+ - `11 Landscape`: `1194 x 834`, close to an 11-inch iPad landscape viewport.
53
+
54
+ Use `Reload` to reload only the framed mobile app. Use `Open` to launch the framed URL directly.
55
+
56
+ ## What This Catches
57
+
58
+ - Tablet breakpoint regressions.
59
+ - Horizontal overflow.
60
+ - Portrait single-column sizing.
61
+ - Landscape list/detail split layout.
62
+ - Detail pane modal-vs-region behavior.
63
+ - Composer and bottom navigation placement.
64
+ - Search and session-card density at iPad widths.
65
+
66
+ ## What Still Requires A Real iPad
67
+
68
+ The preview does not replace Safari-on-iPad validation. Always do one physical-device pass before shipping high-risk mobile changes because desktop browser framing does not fully emulate:
69
+
70
+ - iPad Safari viewport height changes when the software keyboard opens.
71
+ - PWA standalone mode.
72
+ - touch momentum scrolling and rubber-band edges.
73
+ - safe-area inset differences.
74
+ - iOS third-party cookie and passkey behavior.
75
+ - real camera/file-picker attachment flows.
76
+
77
+ ## Automated Companion Test
78
+
79
+ The existing mobile render suite covers the same core tablet breakpoints:
80
+
81
+ ```bash
82
+ npm --prefix claude-task-manager exec playwright test -- \
83
+ --config tests/rendering/playwright.config.js \
84
+ scenarios/mobile-pwa.spec.js \
85
+ -g "iPad|tablet|iPad preview"
86
+ ```
87
+
88
+ Use the automated test for regression gates and `/ipad.html` for quick visual inspection.
@@ -459,7 +459,9 @@ function _realpath(p) {
459
459
  // - isCanonical: path lives under an agent-owned <repo>/.claude|.walle/worktrees/<name>
460
460
  // - isGhost: path contains /~/ corruption OR path doesn't exist on disk
461
461
  // - ahead/behind vs mainBranch
462
- // - dirtyFiles: count of modified+untracked
462
+ // - dirtyFiles: count of tracked dirty + untracked files
463
+ // - trackedDirtyFiles/untrackedFiles: split counts so untracked-only
464
+ // generated artifacts do not block safe sync from main
463
465
  // - unmergedCommits: rev-list count main..HEAD (0 ⇒ safe to delete)
464
466
  // - lastActivity: ISO timestamp of HEAD commit
465
467
  // - summary: 1-line human-readable status
@@ -476,6 +478,98 @@ async function _gitSafe(cwd, args, timeoutMs = 5000) {
476
478
  });
477
479
  }
478
480
 
481
+ async function _gitSafeRaw(cwd, args, timeoutMs = 5000) {
482
+ return new Promise((resolve) => {
483
+ execFile('git', args, { cwd, timeout: timeoutMs, maxBuffer: 1024 * 1024 }, (err, stdout) => {
484
+ if (err) return resolve(null);
485
+ resolve(String(stdout || ''));
486
+ });
487
+ });
488
+ }
489
+
490
+ function _parsePorcelainStatusZ(out) {
491
+ const entries = String(out || '').split('\0').filter(Boolean);
492
+ const result = {
493
+ dirtyFiles: 0,
494
+ trackedDirtyFiles: 0,
495
+ untrackedFiles: 0,
496
+ ignoredFiles: 0,
497
+ unmergedFiles: 0,
498
+ stagedFiles: 0,
499
+ unstagedFiles: 0,
500
+ untrackedPaths: [],
501
+ };
502
+
503
+ for (let i = 0; i < entries.length; i++) {
504
+ const entry = entries[i];
505
+ if (entry.length < 3) continue;
506
+ const xy = entry.slice(0, 2);
507
+ const filePath = entry.slice(3);
508
+ if (xy === '??') {
509
+ result.untrackedFiles += 1;
510
+ result.untrackedPaths.push(filePath);
511
+ continue;
512
+ }
513
+ if (xy === '!!') {
514
+ result.ignoredFiles += 1;
515
+ continue;
516
+ }
517
+
518
+ result.trackedDirtyFiles += 1;
519
+ if (xy[0] && xy[0] !== ' ') result.stagedFiles += 1;
520
+ if (xy[1] && xy[1] !== ' ') result.unstagedFiles += 1;
521
+ if (xy.includes('U') || ['AA', 'DD'].includes(xy)) result.unmergedFiles += 1;
522
+ // In porcelain v1 -z, rename/copy records include the source path as a
523
+ // second NUL-terminated field. Skip it so one rename counts once.
524
+ if (xy[0] === 'R' || xy[0] === 'C') i += 1;
525
+ }
526
+
527
+ result.dirtyFiles = result.trackedDirtyFiles + result.untrackedFiles;
528
+ result.hasOnlyUntrackedFiles = result.untrackedFiles > 0 && result.trackedDirtyFiles === 0;
529
+ return result;
530
+ }
531
+
532
+ function _trackedDirtyCount(wt) {
533
+ if (!wt) return 0;
534
+ if (wt.trackedDirtyFiles != null) return Number(wt.trackedDirtyFiles || 0);
535
+ const dirty = Number(wt.dirtyFiles || 0);
536
+ const untracked = Number(wt.untrackedFiles || 0);
537
+ return Math.max(0, dirty - untracked);
538
+ }
539
+
540
+ function _hasOnlyUntrackedFiles(wt) {
541
+ if (!wt) return false;
542
+ const dirty = Number(wt.dirtyFiles || 0);
543
+ return dirty > 0 && _trackedDirtyCount(wt) === 0;
544
+ }
545
+
546
+ function _splitNulPaths(out) {
547
+ return String(out || '').split('\0').filter(Boolean);
548
+ }
549
+
550
+ function _pathsOverlap(a, b) {
551
+ const left = String(a || '').replace(/\/+$/, '');
552
+ const right = String(b || '').replace(/\/+$/, '');
553
+ if (!left || !right) return false;
554
+ return left === right || left.startsWith(right + '/') || right.startsWith(left + '/');
555
+ }
556
+
557
+ async function _untrackedSyncCollisions(worktreePath, targetBranch) {
558
+ const status = _parsePorcelainStatusZ(await _gitSafeRaw(worktreePath, ['status', '--porcelain=v1', '-uall', '-z']) || '');
559
+ if (status.trackedDirtyFiles > 0 || status.untrackedPaths.length === 0) {
560
+ return { status, collisions: [] };
561
+ }
562
+ const changedOut = await _gitSafeRaw(worktreePath, ['diff', '--name-only', '-z', 'HEAD', targetBranch], 8000);
563
+ const changedPaths = _splitNulPaths(changedOut);
564
+ if (changedPaths.length === 0) return { status, collisions: [] };
565
+ const collisions = [];
566
+ for (const untrackedPath of status.untrackedPaths) {
567
+ const hit = changedPaths.find(changedPath => _pathsOverlap(untrackedPath, changedPath));
568
+ if (hit) collisions.push({ untrackedPath, incomingPath: hit });
569
+ }
570
+ return { status, collisions };
571
+ }
572
+
479
573
  function _checkpointRefSlug(branchName) {
480
574
  let slug = String(branchName || 'branch')
481
575
  .replace(/[^A-Za-z0-9._-]+/g, '-')
@@ -621,7 +715,7 @@ function _classifyState(wt) {
621
715
  if (wt.isGhost) return 'ghost';
622
716
  if (wt.isMain) return 'primary';
623
717
  if (!wt.branch || wt.branch === 'HEAD') return 'detached';
624
- if (wt.dirtyFiles > 0) return 'dirty';
718
+ if (_trackedDirtyCount(wt) > 0) return 'dirty';
625
719
  const ahead = wt.ahead || 0;
626
720
  const behind = wt.behind || 0;
627
721
  if (ahead > 0 && behind > 0) return 'diverged';
@@ -654,16 +748,20 @@ function _buildSummary(wt) {
654
748
  return 'Primary worktree';
655
749
  }
656
750
  if (wt.state === 'detached') return 'Detached HEAD — commits here are at risk. Click Recover branch.';
751
+ const trackedDirty = _trackedDirtyCount(wt);
752
+ const untrackedFiles = Number(wt.untrackedFiles || 0);
657
753
  const parts = [];
658
754
  if (wt.ahead > 0) parts.push(`${wt.ahead} ahead`);
659
755
  if (wt.behind > 0) parts.push(`${wt.behind} behind`);
660
- if (wt.dirtyFiles > 0) parts.push(`${wt.dirtyFiles} dirty`);
756
+ if (trackedDirty > 0) parts.push(`${trackedDirty} dirty`);
757
+ if (untrackedFiles > 0) parts.push(`${untrackedFiles} untracked`);
661
758
  if (parts.length === 0) return 'Clean — synced with main';
662
759
  let suffix = '';
663
760
  if (wt.state === 'ahead') suffix = ' — ready to merge';
664
761
  else if (wt.state === 'behind') suffix = ' — sync from main';
665
762
  else if (wt.state === 'diverged') suffix = ' — needs sync first';
666
763
  else if (wt.state === 'dirty') suffix = ' — commit or stash';
764
+ else if (wt.state === 'clean' && untrackedFiles > 0) suffix = ' — untracked files kept local';
667
765
  return parts.join(', ') + suffix;
668
766
  }
669
767
 
@@ -729,10 +827,12 @@ function _recommendedAction(wt) {
729
827
  return { kind: 'primary', label: 'Primary', tone: 'neutral', reason: 'Main checkout.' };
730
828
  }
731
829
  if (!wt.branch || wt.branch === 'HEAD') return { kind: 'recover_branch', label: 'Recover branch', tone: 'danger', reason: 'Detached HEAD commits can become hard to find.' };
732
- if (wt.dirtyFiles > 0) return { kind: 'review_dirty', label: 'Open session', tone: 'warning', reason: `${wt.dirtyFiles} uncommitted file(s).` };
830
+ const trackedDirty = _trackedDirtyCount(wt);
831
+ if (trackedDirty > 0) return { kind: 'review_dirty', label: 'Open session', tone: 'warning', reason: `${trackedDirty} tracked dirty file(s).` };
733
832
  if ((wt.ahead || 0) > 0 && (wt.behind || 0) > 0) return { kind: 'sync_branch', label: 'Sync first', tone: 'warning', reason: 'Branch has commits and is behind main.' };
734
833
  if ((wt.behind || 0) > 0) return { kind: 'sync_branch', label: 'Sync', tone: 'warning', reason: 'Branch is behind main.' };
735
834
  if ((wt.ahead || 0) > 0) return { kind: 'finish_work', label: 'Finish', tone: 'success', reason: 'Branch has committed work not on main.' };
835
+ if (_hasOnlyUntrackedFiles(wt)) return { kind: 'review_dirty', label: 'Open session', tone: 'warning', reason: `${wt.untrackedFiles || wt.dirtyFiles} untracked file(s).` };
736
836
  return { kind: 'cleanup', label: 'Clean up', tone: 'neutral', reason: 'No unmerged commits or dirty files.' };
737
837
  }
738
838
 
@@ -830,6 +930,37 @@ async function syncWorktree(cwd, branchName, strategy, opts) {
830
930
  message: 'Worktree HEAD changed before sync could start.',
831
931
  };
832
932
  }
933
+ // Make sure main is up to date locally — try a fast-forward fetch but
934
+ // don't fail if there's no remote.
935
+ await _gitSafe(repoRoot, ['fetch', 'origin', 'main', '--quiet'], 8000);
936
+
937
+ // Pre-check for conflicts using merge-tree (git 2.38+).
938
+ const preCheck = await mergeWorktreePreCheck(repoRoot, 'main', branchName).catch(() => ({ conflicts: false }));
939
+ if (preCheck.conflicts) {
940
+ return { merged: false, conflicts: true, message: 'Merge would conflict — open the worktree in a terminal to resolve.' };
941
+ }
942
+
943
+ const syncSafety = await _untrackedSyncCollisions(wt.path, 'main');
944
+ if (syncSafety.status.trackedDirtyFiles > 0) {
945
+ return {
946
+ merged: false,
947
+ blocked: true,
948
+ code: 'TRACKED_DIRTY',
949
+ beforeHead,
950
+ message: 'Commit or stash tracked dirty files before syncing from main.',
951
+ };
952
+ }
953
+ if (syncSafety.collisions.length > 0) {
954
+ return {
955
+ merged: false,
956
+ blocked: true,
957
+ code: 'UNTRACKED_COLLISION',
958
+ beforeHead,
959
+ untrackedCollisions: syncSafety.collisions,
960
+ message: `Sync would overwrite untracked file(s): ${syncSafety.collisions.map(c => c.untrackedPath).slice(0, 3).join(', ')}`,
961
+ };
962
+ }
963
+
833
964
  let checkpointRef = '';
834
965
  if (opts.createCheckpoint) {
835
966
  checkpointRef = await _createWorktreeCheckpoint(repoRoot, branchName, beforeHead);
@@ -844,16 +975,6 @@ async function syncWorktree(cwd, branchName, strategy, opts) {
844
975
  }
845
976
  }
846
977
 
847
- // Make sure main is up to date locally — try a fast-forward fetch but
848
- // don't fail if there's no remote.
849
- await _gitSafe(repoRoot, ['fetch', 'origin', 'main', '--quiet'], 8000);
850
-
851
- // Pre-check for conflicts using merge-tree (git 2.38+).
852
- const preCheck = await mergeWorktreePreCheck(repoRoot, 'main', branchName).catch(() => ({ conflicts: false }));
853
- if (preCheck.conflicts) {
854
- return { merged: false, conflicts: true, message: 'Merge would conflict — open the worktree in a terminal to resolve.' };
855
- }
856
-
857
978
  const args = strategy === 'rebase' ? ['rebase', 'main'] : ['merge', 'main', '--no-edit'];
858
979
  const out = await _gitSafe(wt.path, args, 30000);
859
980
  if (out === null) {
@@ -873,7 +994,8 @@ function _syncAllEligibility(wt) {
873
994
  if (!wt || wt.isMain) return { eligible: false, reason: 'primary checkout' };
874
995
  if (wt.isGhost || wt.state === 'ghost') return { eligible: false, reason: 'ghost worktree' };
875
996
  if (!wt.branch || wt.branch === 'HEAD' || wt.state === 'detached') return { eligible: false, reason: 'detached HEAD' };
876
- if ((wt.dirtyFiles || 0) > 0) return { eligible: false, reason: `${wt.dirtyFiles} dirty file(s)` };
997
+ const trackedDirty = _trackedDirtyCount(wt);
998
+ if (trackedDirty > 0) return { eligible: false, reason: `${trackedDirty} tracked dirty file(s)` };
877
999
  if ((wt.behind || 0) <= 0) return { eligible: false, reason: 'not behind main' };
878
1000
  return { eligible: true, reason: '' };
879
1001
  }
@@ -1058,7 +1180,7 @@ async function listRichWorktrees(cwd, opts) {
1058
1180
  // Gather status fields in parallel.
1059
1181
  const [revlist, statusOut, lastIso, unmergedOut] = await Promise.all([
1060
1182
  wt.branch ? _gitSafe(wt.path, ['rev-list', '--left-right', '--count', `${mainBranch}...${wt.branch}`]) : Promise.resolve(null),
1061
- _gitSafe(wt.path, ['status', '--porcelain']),
1183
+ _gitSafeRaw(wt.path, ['status', '--porcelain=v1', '-uall', '-z']),
1062
1184
  _gitSafe(wt.path, ['log', '-1', '--format=%cI', 'HEAD']),
1063
1185
  wt.branch && !wt.isMain ? _gitSafe(wt.path, ['rev-list', '--count', `${mainBranch}..HEAD`]) : Promise.resolve('0'),
1064
1186
  ]);
@@ -1071,11 +1193,18 @@ async function listRichWorktrees(cwd, opts) {
1071
1193
  ahead = parseInt(parts[1], 10) || 0;
1072
1194
  }
1073
1195
  }
1074
- const dirtyFiles = statusOut ? statusOut.split('\n').filter(Boolean).length : 0;
1196
+ const status = _parsePorcelainStatusZ(statusOut || '');
1197
+ const dirtyFiles = status.dirtyFiles;
1075
1198
  const unmergedCommits = parseInt(unmergedOut, 10) || 0;
1076
1199
 
1077
1200
  const out = {
1078
1201
  ...wt, isGhost: false, isCanonical, ahead, behind, dirtyFiles, unmergedCommits,
1202
+ trackedDirtyFiles: status.trackedDirtyFiles,
1203
+ untrackedFiles: status.untrackedFiles,
1204
+ unmergedFiles: status.unmergedFiles,
1205
+ stagedFiles: status.stagedFiles,
1206
+ unstagedFiles: status.unstagedFiles,
1207
+ hasOnlyUntrackedFiles: status.hasOnlyUntrackedFiles,
1079
1208
  lastActivity: lastIso || null,
1080
1209
  lastActivityRel: _formatRelativeTime(lastIso),
1081
1210
  mainBranch,
@@ -17,10 +17,21 @@ function nowMs() {
17
17
  return Date.now();
18
18
  }
19
19
 
20
+ function normalizeIpLockKey(ip) {
21
+ let key = String(ip || '').trim();
22
+ if (!key) return '';
23
+ if (key.startsWith('[') && key.endsWith(']')) key = key.slice(1, -1);
24
+ const mappedIpv4 = key.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
25
+ return mappedIpv4 ? mappedIpv4[1] : key;
26
+ }
27
+
20
28
  class AuthRateLimiter {
21
29
  constructor(options = {}) {
22
30
  this.limits = { ...DEFAULT_LIMITS, ...(options.limits || {}) };
23
31
  this.failedAuth = { ...DEFAULT_FAILED_AUTH, ...(options.failedAuth || {}) };
32
+ this.ipLockoutExemptions = new Set((options.ipLockoutExemptions || [])
33
+ .map(normalizeIpLockKey)
34
+ .filter(Boolean));
24
35
  this.buckets = new Map();
25
36
  this.failures = new Map();
26
37
  }
@@ -50,9 +61,15 @@ class AuthRateLimiter {
50
61
  return { ok: true };
51
62
  }
52
63
 
64
+ isIpLockoutExempt(ip) {
65
+ const key = normalizeIpLockKey(ip);
66
+ return !!key && this.ipLockoutExemptions.has(key);
67
+ }
68
+
53
69
  isIpLocked(ip, atMs = nowMs()) {
54
- const key = String(ip || '');
70
+ const key = normalizeIpLockKey(ip);
55
71
  if (!key) return { ok: true };
72
+ if (this.ipLockoutExemptions.has(key)) return { ok: true, exempt: true };
56
73
  const state = this.failures.get(key);
57
74
  if (!state || !state.lockedUntil || state.lockedUntil <= atMs) {
58
75
  if (state && state.lockedUntil && state.lockedUntil <= atMs) this.failures.delete(key);
@@ -66,8 +83,9 @@ class AuthRateLimiter {
66
83
  }
67
84
 
68
85
  recordAuthFailure(ip, atMs = nowMs()) {
69
- const key = String(ip || '');
86
+ const key = normalizeIpLockKey(ip);
70
87
  if (!key) return { locked: false };
88
+ if (this.ipLockoutExemptions.has(key)) return { locked: false, count: 0, lockedUntil: null, exempt: true };
71
89
  const existing = this.failures.get(key);
72
90
  const windowStart = existing && atMs - existing.windowStart < this.failedAuth.windowMs
73
91
  ? existing.windowStart
@@ -82,7 +100,8 @@ class AuthRateLimiter {
82
100
  }
83
101
 
84
102
  recordAuthSuccess(ip) {
85
- const key = String(ip || '');
103
+ const key = normalizeIpLockKey(ip);
104
+ if (this.ipLockoutExemptions.has(key)) return;
86
105
  if (key) this.failures.delete(key);
87
106
  }
88
107
 
@@ -103,5 +122,6 @@ module.exports = {
103
122
  AuthRateLimiter,
104
123
  DEFAULT_FAILED_AUTH,
105
124
  DEFAULT_LIMITS,
125
+ normalizeIpLockKey,
106
126
  rateKindForRule,
107
127
  };
@@ -82,8 +82,11 @@ const HTTP_AUTH_RULES = Object.freeze([
82
82
  exact('GET', '/api/recent-sessions', 'read', false, 'never'),
83
83
  exact('GET', '/api/sessions/standup', 'read', false, 'never'),
84
84
  exact('GET', '/api/sessions/git-status', 'read', false, 'never'),
85
+ exact('GET', '/api/session/prompts', 'read', false, 'never'),
85
86
  exact('GET', '/api/session/messages', 'read', false, 'never'),
86
87
  exact('GET', '/api/session/export', 'read', false, 'never'),
88
+ exact('GET', '/api/session/prompts', 'read', false, 'never'),
89
+ exact('POST', '/api/session/image-refs', 'respond', true, 'never'),
87
90
  exact('GET', '/api/sessions/integrity', 'read', false, 'never'),
88
91
  exact('GET', '/api/sessions/relink-audit', 'read', false, 'never'),
89
92
  exact('GET', '/api/sessions/search', 'read', false, 'never'),
@@ -49,6 +49,38 @@ function splitPathAndLine(input, fallbackLine = 1) {
49
49
  return { rawPath: raw, line };
50
50
  }
51
51
 
52
+ function realpathOrResolve(value) {
53
+ const resolved = path.resolve(value);
54
+ try { return fs.realpathSync.native ? fs.realpathSync.native(resolved) : fs.realpathSync(resolved); }
55
+ catch { return resolved; }
56
+ }
57
+
58
+ function normalizeBaseCwd(cwd) {
59
+ if (!cwd) return '';
60
+ const raw = String(cwd || '').trim();
61
+ if (!raw || !path.isAbsolute(raw)) return '';
62
+ try {
63
+ const stat = fs.statSync(raw);
64
+ const dir = stat.isDirectory() ? raw : path.dirname(raw);
65
+ return realpathOrResolve(dir);
66
+ } catch {
67
+ return '';
68
+ }
69
+ }
70
+
71
+ function resolveInputPath(rawPath, options = {}) {
72
+ if (!rawPath) return '';
73
+ if (/^~(?:\/|$)/.test(rawPath)) {
74
+ const home = options.home || process.env.HOME || os.homedir();
75
+ if (!home) return '';
76
+ return path.resolve(home, rawPath === '~' ? '' : rawPath.slice(2));
77
+ }
78
+ if (path.isAbsolute(rawPath)) return path.resolve(rawPath);
79
+ const baseCwd = normalizeBaseCwd(options.cwd);
80
+ if (!baseCwd) throw httpError('relative document path requires a session cwd', 400, 'EINVAL');
81
+ return path.resolve(baseCwd, rawPath);
82
+ }
83
+
52
84
  function projectRootFor(filePath) {
53
85
  const cwd = fs.statSync(filePath).isDirectory() ? filePath : path.dirname(filePath);
54
86
  try {
@@ -65,9 +97,8 @@ function projectRootFor(filePath) {
65
97
  function resolveDocumentTarget(input, options = {}) {
66
98
  const { rawPath, line } = splitPathAndLine(input, options.line || 1);
67
99
  if (!rawPath) throw httpError('path required', 400, 'EINVAL');
68
- if (!path.isAbsolute(rawPath)) throw httpError('absolute path required', 400, 'EINVAL');
69
100
 
70
- const resolved = path.resolve(rawPath);
101
+ const resolved = resolveInputPath(rawPath, options);
71
102
  let realPath = '';
72
103
  try {
73
104
  const stat = fs.statSync(resolved);
@@ -1129,6 +1129,7 @@ function microsoftDevTunnelCommands(options = {}) {
1129
1129
  login: command('devtunnel', ['user', 'login', '-g']),
1130
1130
  device_login: command('devtunnel', ['user', 'login', '-g', '-d']),
1131
1131
  microsoft_device_login: command('devtunnel', ['user', 'login', '-e', '-d']),
1132
+ logout: command('devtunnel', ['user', 'logout']),
1132
1133
  access_private_reset: command('devtunnel', devTunnelPrivateAccessResetArgs('TUNNELID', { port })),
1133
1134
  host_temporary: command('devtunnel', [
1134
1135
  'host',
@@ -1704,6 +1705,87 @@ function loginStateIsReusable(state, provider, options = {}) {
1704
1705
  return now - started < Number(options.loginReuseMs || LOGIN_REUSE_MS);
1705
1706
  }
1706
1707
 
1708
+ async function logoutMicrosoftDevTunnelUser(options = {}) {
1709
+ const previous = loadLoginProcessState(options);
1710
+ if (previous && previous.running && pidIsRunning(previous.pid)) {
1711
+ stopLoginProcessState(previous, options);
1712
+ }
1713
+
1714
+ const execFile = options.execFile || defaultExecFile;
1715
+ const args = ['user', 'logout'];
1716
+ const baseState = {
1717
+ ...(previous || {}),
1718
+ running: false,
1719
+ pid: null,
1720
+ provider: 'github',
1721
+ provider_label: loginProviderLabel('github'),
1722
+ command: command('devtunnel', args).display,
1723
+ login_url: '',
1724
+ device_code: '',
1725
+ error: '',
1726
+ error_code: '',
1727
+ signed_out_at: new Date().toISOString(),
1728
+ };
1729
+ try {
1730
+ const result = await execFile('devtunnel', args, {
1731
+ encoding: 'utf8',
1732
+ timeout: options.commandTimeoutMs || 15000,
1733
+ maxBuffer: 512 * 1024,
1734
+ });
1735
+ const stoppedTunnel = options.stopTunnel === false ? null : await stopMicrosoftDevTunnel(options).catch((err) => ({
1736
+ ok: false,
1737
+ error: String(err?.message || err || 'Could not stop existing Microsoft tunnel').slice(0, 800),
1738
+ }));
1739
+ const nextState = {
1740
+ ...baseState,
1741
+ stdout: String(result?.stdout || '').trim().slice(0, 800),
1742
+ stderr: String(result?.stderr || '').trim().slice(0, 800),
1743
+ };
1744
+ writeLoginProcessState(nextState, options);
1745
+ return {
1746
+ ok: true,
1747
+ already_signed_out: false,
1748
+ stopped_tunnel: stoppedTunnel,
1749
+ logout: { ...nextState, state_path: loginStatePath(options) },
1750
+ setup: await detectMicrosoftDevTunnelSetup(options),
1751
+ progress: getMicrosoftDevTunnelProgress(options),
1752
+ };
1753
+ } catch (err) {
1754
+ const text = String(`${err?.stdout || ''}\n${err?.stderr || ''}\n${err?.message || ''}`);
1755
+ const alreadySignedOut = /not\s+(?:logged|signed)\s+in|no\s+user|login\s+required/i.test(text);
1756
+ const nextState = {
1757
+ ...baseState,
1758
+ already_signed_out: alreadySignedOut,
1759
+ error_code: alreadySignedOut ? '' : (err?.code === 'ENOENT' ? 'devtunnel_cli_missing' : 'devtunnel_logout_failed'),
1760
+ error: alreadySignedOut ? '' : text.trim().slice(0, 800),
1761
+ };
1762
+ writeLoginProcessState(nextState, options);
1763
+ if (alreadySignedOut) {
1764
+ const stoppedTunnel = options.stopTunnel === false ? null : await stopMicrosoftDevTunnel(options).catch((stopErr) => ({
1765
+ ok: false,
1766
+ error: String(stopErr?.message || stopErr || 'Could not stop existing Microsoft tunnel').slice(0, 800),
1767
+ }));
1768
+ return {
1769
+ ok: true,
1770
+ already_signed_out: true,
1771
+ stopped_tunnel: stoppedTunnel,
1772
+ logout: { ...nextState, state_path: loginStatePath(options) },
1773
+ setup: await detectMicrosoftDevTunnelSetup(options),
1774
+ progress: getMicrosoftDevTunnelProgress(options),
1775
+ };
1776
+ }
1777
+ return {
1778
+ ok: false,
1779
+ already_signed_out: false,
1780
+ error_code: nextState.error_code,
1781
+ error: nextState.error || 'Microsoft Dev Tunnels sign-out failed.',
1782
+ logout: { ...nextState, state_path: loginStatePath(options) },
1783
+ setup: await detectMicrosoftDevTunnelSetup(options).catch(() => null),
1784
+ progress: getMicrosoftDevTunnelProgress(options),
1785
+ };
1786
+ }
1787
+ }
1788
+
1707
1789
  function startMicrosoftDevTunnelLogin(input = {}, options = {}) {
1708
1790
  const fsImpl = options.fs || fs;
1709
1791
  fsImpl.mkdirSync(setupDir(options), { recursive: true, mode: 0o700 });
@@ -1804,6 +1886,7 @@ module.exports = {
1804
1886
  loadKeepAwakeState,
1805
1887
  loadManagedTunnelState,
1806
1888
  loadLoginProcessState,
1889
+ logoutMicrosoftDevTunnelUser,
1807
1890
  managedTunnelDesiredRunning,
1808
1891
  microsoftDevTunnelCommands,
1809
1892
  normalizeTunnelId,
@@ -7,6 +7,7 @@ const { requestBrowserOrigin, requestOrigin } = require('./transport-security');
7
7
  const CLAIM_PUBLIC_ENDPOINTS = new Set([
8
8
  'POST /api/auth/claim/begin-passkey',
9
9
  'POST /api/auth/claim/finish',
10
+ 'POST /api/auth/logout-local',
10
11
  ]);
11
12
 
12
13
  let simpleWebAuthnPromise = null;
@@ -92,6 +93,15 @@ function errorJson(res, status, code, message) {
92
93
  sendJson(res, status, { ok: false, error: code, message: message || code });
93
94
  }
94
95
 
96
+ function clearLocalSession(req, res) {
97
+ sendJson(res, 200, { ok: true }, {
98
+ 'Set-Cookie': [
99
+ tokenCookie('', requestIsHttps(req), 0),
100
+ stepUpCookie('', requestIsHttps(req), 0),
101
+ ],
102
+ });
103
+ }
104
+
95
105
  function requestIsHttps(req) {
96
106
  return !!req?.socket?.encrypted || String(req?.headers?.['x-forwarded-proto'] || '').split(',')[0].trim().toLowerCase() === 'https';
97
107
  }
@@ -532,6 +542,10 @@ async function handleMobileAuthApi(req, res, url, options = {}) {
532
542
  await finishClaimPasskey(req, res, db);
533
543
  return true;
534
544
  }
545
+ if (url.pathname === '/api/auth/logout-local' && req.method === 'POST') {
546
+ clearLocalSession(req, res);
547
+ return true;
548
+ }
535
549
 
536
550
  if (url.pathname === '/api/auth/device-claims' && req.method === 'POST') {
537
551
  const body = await readJsonBody(req);
@@ -0,0 +1,68 @@
1
+ 'use strict';
2
+
3
+ const BLOCKING_STATUSES = new Set(['running', 'busy', 'waiting_input']);
4
+
5
+ function normalizeStatus(value) {
6
+ const text = String(value || '').trim().toLowerCase().replace(/[-\s]+/g, '_');
7
+ if (!text) return '';
8
+ if (text === 'waiting' || text === 'waiting_for_input' || text === 'waiting_input') return 'waiting_input';
9
+ if (text === 'busy') return 'running';
10
+ return text;
11
+ }
12
+
13
+ function sessionLabel(session) {
14
+ return String(
15
+ session?.label ||
16
+ session?.title ||
17
+ session?.name ||
18
+ session?.id ||
19
+ ''
20
+ ).trim();
21
+ }
22
+
23
+ function sessionId(session) {
24
+ return String(session?.id || session?.sessionId || session?.ctmSessionId || '').trim();
25
+ }
26
+
27
+ function restartSessionSummary(session, status, waitingForInput) {
28
+ const normalized = waitingForInput ? 'waiting_input' : normalizeStatus(status);
29
+ return {
30
+ id: sessionId(session),
31
+ label: sessionLabel(session),
32
+ status: normalized || 'idle',
33
+ };
34
+ }
35
+
36
+ function summarizeRestartGuard(sessions = [], options = {}) {
37
+ const items = Array.from(sessions || []).filter(Boolean);
38
+ const blockers = [];
39
+ const restorable = [];
40
+
41
+ for (const session of items) {
42
+ const rawStatus = typeof options.statusForSession === 'function'
43
+ ? options.statusForSession(session)
44
+ : (session.standupStatus || session.liveStatus || session.serverState || session.status || '');
45
+ const waitingForInput = typeof options.isWaitingForInput === 'function'
46
+ ? !!options.isWaitingForInput(session)
47
+ : !!session.waitingForInput;
48
+ const summary = restartSessionSummary(session, rawStatus, waitingForInput);
49
+ if (BLOCKING_STATUSES.has(summary.status)) blockers.push(summary);
50
+ else restorable.push(summary);
51
+ }
52
+
53
+ return {
54
+ totalCount: items.length,
55
+ blockingCount: blockers.length,
56
+ restorableCount: restorable.length,
57
+ blockers,
58
+ restorable,
59
+ };
60
+ }
61
+
62
+ module.exports = {
63
+ summarizeRestartGuard,
64
+ _private: {
65
+ normalizeStatus,
66
+ restartSessionSummary,
67
+ },
68
+ };