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.
- package/README.md +5 -5
- package/package.json +2 -2
- package/template/claude-task-manager/api-prompts.js +13 -0
- package/template/claude-task-manager/api-reviews.js +5 -2
- package/template/claude-task-manager/db.js +348 -15
- package/template/claude-task-manager/docs/app-update-refresh-protocol.md +69 -0
- package/template/claude-task-manager/docs/image-paste-ux.md +3 -0
- package/template/claude-task-manager/docs/ipad-web-preview.md +88 -0
- package/template/claude-task-manager/git-utils.js +146 -17
- package/template/claude-task-manager/lib/auth-rate-limit.js +23 -3
- package/template/claude-task-manager/lib/auth-rules.js +3 -0
- package/template/claude-task-manager/lib/document-review.js +33 -2
- package/template/claude-task-manager/lib/microsoft-dev-tunnel-setup.js +83 -0
- package/template/claude-task-manager/lib/mobile-auth-api.js +14 -0
- package/template/claude-task-manager/lib/restart-guard.js +68 -0
- package/template/claude-task-manager/lib/session-standup.js +36 -13
- package/template/claude-task-manager/lib/session-stream.js +11 -4
- package/template/claude-task-manager/lib/transport-security.js +50 -0
- package/template/claude-task-manager/lib/walle-transcript.js +16 -0
- package/template/claude-task-manager/lib/worktree-active-sync.js +6 -3
- package/template/claude-task-manager/public/css/reviews.css +10 -0
- package/template/claude-task-manager/public/css/setup.css +13 -0
- package/template/claude-task-manager/public/css/walle.css +145 -0
- package/template/claude-task-manager/public/index.html +539 -44
- package/template/claude-task-manager/public/ipad.html +363 -0
- package/template/claude-task-manager/public/js/document-review-links.js +196 -0
- package/template/claude-task-manager/public/js/message-renderer.js +14 -3
- package/template/claude-task-manager/public/js/reviews.js +30 -6
- package/template/claude-task-manager/public/js/setup.js +42 -2
- package/template/claude-task-manager/public/js/stream-view.js +20 -1
- package/template/claude-task-manager/public/js/walle.js +314 -18
- package/template/claude-task-manager/public/m/app.css +789 -11
- package/template/claude-task-manager/public/m/app.js +1070 -67
- package/template/claude-task-manager/public/m/claim.html +9 -2
- package/template/claude-task-manager/public/m/index.html +17 -10
- package/template/claude-task-manager/public/m/sw.js +1 -1
- package/template/claude-task-manager/server.js +365 -95
- package/template/claude-task-manager/session-integrity.js +4 -0
- package/template/docs/designs/2026-05-17-portkey-gateway-provider-ux.md +86 -35
- package/template/package.json +1 -1
- package/template/wall-e/api-walle.js +19 -1
- package/template/wall-e/brain.js +152 -6
- package/template/wall-e/chat.js +85 -0
- package/template/wall-e/coding-orchestrator.js +106 -12
- package/template/wall-e/http/model-admin.js +131 -0
- package/template/wall-e/lib/service-health.js +194 -0
- package/template/wall-e/llm/anthropic.js +7 -0
- package/template/wall-e/llm/client.js +46 -12
- package/template/wall-e/llm/openai.js +17 -2
- package/template/wall-e/llm/portkey-sync.js +201 -0
- package/template/wall-e/server.js +13 -0
- 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
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
+
};
|