loopat 0.1.50 → 0.1.52

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loopat",
3
- "version": "0.1.50",
3
+ "version": "0.1.52",
4
4
  "description": "Self-hosted AI workspace built around context management — works solo, scales to teams",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://github.com/simpx/loopat",
@@ -39,7 +39,10 @@
39
39
  "test:e2e": "playwright test",
40
40
  "test:e2e:ui": "playwright test --ui",
41
41
  "dogfood": "playwright test --config dogfood/playwright.config.ts",
42
- "dogfood:first-run": "playwright test --config dogfood/first-run/playwright.config.ts"
42
+ "dogfood:smoke": "playwright test --config dogfood/playwright.config.ts dogfood/first-5-minutes",
43
+ "dogfood:journey": "playwright test --config dogfood/first-run/playwright.config.ts",
44
+ "dogfood:first-run": "playwright test --config dogfood/first-run/playwright.config.ts",
45
+ "dogfood:sync": "playwright test --config dogfood/sync/playwright.config.ts"
43
46
  },
44
47
  "dependencies": {
45
48
  "@anthropic-ai/claude-agent-sdk": "^0.3.150",
@@ -4,7 +4,7 @@ import { createBunWebSocket } from "hono/bun"
4
4
  import { existsSync } from "node:fs"
5
5
  import { execFile, execFileSync } from "node:child_process"
6
6
  import { promisify } from "node:util"
7
- import { listLoops, createLoop, getLoop, loopExists, patchLoopMeta, backfillAllMounts, ensureWorkspaceDirs, provisionUserPersonal, importPersonalFromRepo, setupPersonalViaProvider, listPersonalReposViaProvider, authenticateViaProvider, isPersonalFresh, ensureUiNotesWorktree, syncUiNotes, ffUpdateUiNotes, notesBehind, inspectPersonalDirty, syncPersonalToRemote, deletePersonalVault, pullPersonalFromRemote, pushPersonalToRemote, ensureContextMounts, effectiveDriver, isDriver, distillLoop, inspectRepoSync, pullRepoFromRemote, pushRepoToRemote, listVaultPublicKeys, userOnboarding, submitOnboarding } from "./loops"
7
+ import { listLoops, createLoop, getLoop, loopExists, patchLoopMeta, backfillAllMounts, ensureWorkspaceDirs, provisionUserPersonal, importPersonalFromRepo, setupPersonalViaProvider, listPersonalReposViaProvider, authenticateViaProvider, isPersonalFresh, ensureUiNotesWorktree, syncUiNotes, ffUpdateUiNotes, discardUiNotes, notesBehind, inspectPersonalDirty, syncPersonalToRemote, deletePersonalVault, pullPersonalFromRemote, pushPersonalToRemote, ensureContextMounts, effectiveDriver, isDriver, distillLoop, inspectRepoSync, pullRepoFromRemote, pushRepoToRemote, listVaultPublicKeys, userOnboarding, submitOnboarding } from "./loops"
8
8
  import { getEphemeralHostPort, probePodman, stopAllWorkspaceContainers, ensureServeContainer, ensurePortProxyContainer, ensureSandboxImage } from "./podman"
9
9
  import { startMcpAuth, completeMcpAuth, probeOAuthSupport, evictOAuthProbe, parseBearerEnvName, mcpRequiredEnvs, parseTemplateVars, type OAuthSupport } from "./mcp-oauth"
10
10
  import { DEFAULT_VAULT, loadVaultEnvs } from "./vaults"
@@ -2392,6 +2392,15 @@ app.post("/api/notes/refresh", requireAuth, async (c) => {
2392
2392
  return c.json({ ok: true })
2393
2393
  })
2394
2394
 
2395
+ // Take-remote: drop held-back local notes edits, reset hard to origin. The
2396
+ // escape hatch when save reports a same-spot conflict it can't rebase past.
2397
+ app.post("/api/notes/discard", requireAuth, async (c) => {
2398
+ const userId = c.get("userId") as string
2399
+ const r = await discardUiNotes(userId)
2400
+ if (!r.ok) return c.json({ error: r.error }, 400)
2401
+ return c.json({ ok: true })
2402
+ })
2403
+
2395
2404
  app.delete("/api/workspace/file", requireAuth, async (c) => {
2396
2405
  const userId = c.get("userId") as string
2397
2406
  const vault = c.req.query("vault") ?? ""
@@ -453,8 +453,23 @@ async function ensureRepoMirror(user: string, name: string, sshCommand?: string)
453
453
  if (!spec?.git) return null
454
454
  const dir = personalRepoCacheDir(user, name)
455
455
  const env = sshCommand ? { ...process.env, GIT_SSH_COMMAND: sshCommand } : process.env
456
- // Already mirrored just fetch (incremental, fast). HEAD presence == bare repo.
456
+ // Pin the STANDARD fetch refspec (default branch refs/remotes/origin/<def>)
457
+ // so worktrees off this mirror get an ordinary `origin/<def>` tracking ref —
458
+ // `git rebase origin/<def>`, `git status` ahead/behind, `git log origin/<def>`
459
+ // all work as in any normal clone. Self-healing: re-assert it on EVERY ensure
460
+ // (incl. caches pinned by the old non-standard `+…:refs/heads/<def>` refspec),
461
+ // then fetch so the standard ref materializes.
462
+ const assertStandardRefspec = async () => {
463
+ const def = await execFileP("git", ["-C", dir, "symbolic-ref", "--short", "HEAD"])
464
+ .then((r) => r.stdout.trim()).catch(() => "")
465
+ if (def) {
466
+ await execFileP("git", ["-C", dir, "config", "remote.origin.fetch", `+refs/heads/${def}:refs/remotes/origin/${def}`]).catch(() => {})
467
+ }
468
+ }
469
+ // Already mirrored → re-assert the standard refspec, then fetch (incremental,
470
+ // fast). HEAD presence == bare repo.
457
471
  if (existsSyncBase(join(dir, "HEAD"))) {
472
+ await assertStandardRefspec()
458
473
  await execFileP("git", ["-C", dir, "fetch", "--quiet", "origin"], { env, timeout: 60_000 }).catch(() => {})
459
474
  return dir
460
475
  }
@@ -474,13 +489,13 @@ async function ensureRepoMirror(user: string, name: string, sshCommand?: string)
474
489
  await execFileP("git", ["clone", "--bare", "--single-branch", "--", spec.git, dir], { env, timeout: 300_000 })
475
490
  }
476
491
  // A bare clone sets NO fetch refspec, so `git fetch origin` wouldn't advance
477
- // any ref. Pin one for JUST the default branch so future fetches keep
478
- // refs/heads/<default> (= the worktree start point) fresh and stay small.
479
- const def = await execFileP("git", ["-C", dir, "symbolic-ref", "--short", "HEAD"])
480
- .then((r) => r.stdout.trim()).catch(() => "")
481
- if (def) {
482
- await execFileP("git", ["-C", dir, "config", "remote.origin.fetch", `+refs/heads/${def}:refs/heads/${def}`]).catch(() => {})
483
- }
492
+ // any ref. Pin the STANDARD one for JUST the default branch
493
+ // (+refs/heads/<def>:refs/remotes/origin/<def>) so worktrees off this mirror
494
+ // get an ordinary `origin/<def>` tracking ref and the mirror stays small.
495
+ await assertStandardRefspec()
496
+ // Materialize refs/remotes/origin/<def> now (the clone only wrote
497
+ // refs/heads/<def>), so the very first worktree already tracks origin.
498
+ await execFileP("git", ["-C", dir, "fetch", "--quiet", "origin"], { env, timeout: 60_000 }).catch(() => {})
484
499
  console.log(`[loopat] mirrored ${spec.git} → ${dir}`)
485
500
  return dir
486
501
  } catch (e: any) {
@@ -1592,6 +1607,29 @@ export async function ffUpdateUiNotes(
1592
1607
  }
1593
1608
  }
1594
1609
 
1610
+ /**
1611
+ * Take-remote: discard the user's held-back notes edits and reset hard to
1612
+ * origin. The escape hatch from a same-spot conflict the rebase can't clear —
1613
+ * "drop this edit, take the remote" (the other half is keep-mine via a loop AI
1614
+ * merge). Destructive by design; the UI confirms first.
1615
+ */
1616
+ export async function discardUiNotes(
1617
+ user: string,
1618
+ ): Promise<{ ok: true } | { ok: false; error: string }> {
1619
+ const dir = uiNotesDir(user)
1620
+ await ensureUiNotesWorktree(user)
1621
+ const branch = await remoteDefaultBranch(dir)
1622
+ try {
1623
+ await execFileP("git", ["-C", dir, "fetch", "origin"], {
1624
+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_SSH_COMMAND: sshCommandForUser(user) }, timeout: 30_000,
1625
+ })
1626
+ await execFileP("git", ["-C", dir, "reset", "--hard", `origin/${branch}`])
1627
+ return { ok: true }
1628
+ } catch (e: any) {
1629
+ return { ok: false, error: `discard failed: ${e?.stderr ?? e?.message ?? e}` }
1630
+ }
1631
+ }
1632
+
1595
1633
  /**
1596
1634
  * How many commits the user's notes worktree is behind origin/main (after a
1597
1635
  * fetch). Drives the "remote updated" hint. 0 = up to date.
@@ -2059,6 +2097,14 @@ async function remoteDefaultBranch(dir: string): Promise<string> {
2059
2097
  const m = stdout.match(/ref:\s+refs\/heads\/(\S+)\s+HEAD/)
2060
2098
  if (m?.[1]) return m[1]
2061
2099
  } catch {}
2100
+ // Bare mirrors don't carry a refs/remotes/origin/HEAD, but their own HEAD
2101
+ // symbolic-ref names the default branch the clone tracked — cheaper than (and
2102
+ // a fallback for) ls-remote, and correct for `ensureRepoMirror`'s mirrors.
2103
+ try {
2104
+ const { stdout } = await execFileP("git", ["-C", dir, "symbolic-ref", "--short", "HEAD"])
2105
+ const b = stdout.trim().replace(/^origin\//, "")
2106
+ if (b) return b
2107
+ } catch {}
2062
2108
  return "main"
2063
2109
  }
2064
2110
 
@@ -2206,10 +2252,17 @@ export async function createLoop(opts: {
2206
2252
  const branch = `loop/${(await shortBranchSlug(meta.title))}-${id.slice(0, 6)}`
2207
2253
  try {
2208
2254
  // ① pull (docs/context-flow.md): ensureRepoMirror already fetched, so the
2209
- // mirror's HEAD (refs/heads/<default>) is the latest consensus. Worktree
2210
- // the new loop branch off it fresh, and the worktree's `origin` is the
2211
- // real remote, so the loop's pushes go straight upstream.
2212
- await execFileP("git", ["-C", repoPath, "worktree", "add", "-b", branch, loopWorkdir(id)])
2255
+ // mirror's `origin/<default>` tracking ref is the latest consensus. Open
2256
+ // the loop branch off `origin/<default>` (not the bare mirror's HEAD) so
2257
+ // the worktree is an ORDINARY clone: it has a real `origin/<default>`
2258
+ // tracking ref and `git rebase origin/<default>` / `git status`
2259
+ // ahead-behind work with nothing special to learn. Fall back to HEAD only
2260
+ // if origin/<default> can't be resolved (offline / empty remote).
2261
+ const start = await remoteStartPoint(repoPath, userSsh)
2262
+ const addArgs = start
2263
+ ? ["-C", repoPath, "worktree", "add", "-b", branch, loopWorkdir(id), start]
2264
+ : ["-C", repoPath, "worktree", "add", "-b", branch, loopWorkdir(id)]
2265
+ await execFileP("git", addArgs)
2213
2266
  meta.repo = opts.repo
2214
2267
  meta.branch = branch
2215
2268
  } catch (e: any) {
@@ -71,6 +71,7 @@ For team memory: when an insight is genuinely team-relevant (a convention everyo
71
71
  - **Don't echo sensitive values** (API keys, tokens, SSH key material, anything that looks like a credential) to chat. Reference by filename or env var name instead.
72
72
  - **Default to short, direct answers**. Don't announce a plan unless the task is genuinely large.
73
73
  - **Read before Edit on long files**; avoid guessing surrounding context.
74
+ - **`origin` is the source of truth — finishing means pushing to origin.** The workdir is an ordinary git worktree with a normal `origin/<default>` tracking ref, so `git rebase origin/<default>`, `git status` ahead/behind, and `git log origin/<default>` all work as usual. A local commit is NOT "done": work is only preserved and shared once it reaches origin — open a PR, or push directly when that's the team's flow. Don't consider a task complete while it lives only in local commits.
74
75
 
75
76
  ## collaboration
76
77