@agfpd/iapeer 0.4.17 → 0.4.18

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/CHANGELOG.md CHANGED
@@ -10,6 +10,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
10
10
 
11
11
  ## [Unreleased]
12
12
 
13
+ ## [0.4.18] - 2026-06-22
14
+
15
+ ### Changed
16
+
17
+ - `iapeer rename <old> <new>` now performs a **full folder rename** (extends 0.4.17's
18
+ personality-only rename). The personality is a self-healed mirror of `normalize(basename(cwd))`,
19
+ so a rename that kept the cwd would be reverted by profile-standard self-heal — the folder
20
+ must move. The verb now atomically moves the cwd folder (the per-cwd profile, `.mcp.json`,
21
+ native-memory, `CLAUDE.md`, and the `.git` clone ride it), moves the claude transcript slug
22
+ dir (`~/.claude/projects/<slug>`, keyed by `realpath(cwd)` — so claude history is preserved),
23
+ and updates the registry cwd + personality + per-cwd profile, with rollback of the fs moves on
24
+ failure. Best-effort afterwards: re-trust the new cwd for codex + clean the old, rewrite the
25
+ claude `.mcp.json` identity, purge the old identity's lifecycle markers. Refuses a LIVE peer
26
+ unless `--force`. Memory (operativka folder + author/index attribution) re-key is the memory
27
+ provider's separate step; codex resume-history (keyed by the cwd recorded inside each session
28
+ file, not a path-dir) does not carry to the new cwd.
29
+
13
30
  ## [0.4.17] - 2026-06-21
14
31
 
15
32
  ### Added
@@ -257,7 +274,8 @@ Initial public release — the foundation core of the iapeer multi-agent ecosyst
257
274
  - **Heterogeneous peers** — AI agents (Claude, Codex), humans (Telegram), and services
258
275
  (timer = cron, watcher = event) are all first-class and addressed the same way.
259
276
 
260
- [Unreleased]: https://github.com/agfpd/iapeer/compare/v0.4.17...HEAD
277
+ [Unreleased]: https://github.com/agfpd/iapeer/compare/v0.4.18...HEAD
278
+ [0.4.18]: https://github.com/agfpd/iapeer/compare/v0.4.17...v0.4.18
261
279
  [0.4.17]: https://github.com/agfpd/iapeer/compare/v0.4.16...v0.4.17
262
280
  [0.4.16]: https://github.com/agfpd/iapeer/compare/v0.4.15...v0.4.16
263
281
  [0.4.15]: https://github.com/agfpd/iapeer/compare/v0.4.14...v0.4.15
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.4.17",
3
+ "version": "0.4.18",
4
4
  "description": "Foundation core for the iapeer multi-agent ecosystem: identity, registry, storage, codec.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "agfpd",
package/src/cli/index.ts CHANGED
@@ -11,7 +11,7 @@
11
11
  // sentinel-marked always-on plist) are stop/start-able.
12
12
 
13
13
  import { spawnSync } from 'child_process'
14
- import { existsSync, readFileSync } from 'fs'
14
+ import { existsSync, readFileSync, renameSync } from 'fs'
15
15
  import { fileURLToPath } from 'url'
16
16
  import { dirname, join } from 'path'
17
17
  import {
@@ -54,6 +54,7 @@ import { hostRunDir } from '../launch/ptyHost.ts' // pty-only: attach via the su
54
54
  import { runSupervisorClient } from '../supervisor/client.ts' // @xterm-free attach client (both reattach port-deps baked in)
55
55
  import { cycleDaemon, isFoundationOwnedPlist, launchctlBootstrap, launchdLabel, launchdPlistPath } from '../launch/launchd.ts'
56
56
  import { readPeerProfile, renamePeer, resolveCallerIdentity, resolveIdentity, writePeerProfileAtomic } from '../identity/index.ts'
57
+ import { claudeProjectsRoot, transcriptSlug } from '../launch/adapters/claude.ts'
57
58
  import { peerProfilePath } from '../storage/index.ts'
58
59
  import {
59
60
  isConformant,
@@ -836,27 +837,37 @@ export async function removePeerCli(
836
837
  export interface RenameOutcome {
837
838
  oldPersonality: string
838
839
  newPersonality: string
839
- action: 'renamed' | 'absent' | 'target-exists' | 'refused-live'
840
+ action: 'renamed' | 'absent' | 'target-exists' | 'target-cwd-exists' | 'refused-live'
840
841
  reason?: string
841
- cwd?: string
842
+ oldCwd?: string
843
+ newCwd?: string
844
+ transcriptMoved?: boolean
842
845
  purgedState?: string[]
846
+ sideEffects?: string[]
843
847
  }
844
848
 
845
849
  /**
846
- * Rename a peer's personality through renamePeer (registry + per-cwd profile, atomic;
847
- * cwd KEPT — the transcript history keyed by slug=realpath(cwd) survives). Refuses a LIVE
848
- * peer unless --force: a running session carries the OLD identity in its env
849
- * (PEER_PERSONALITY, set at launch), so renaming under it desyncs the registry from the
850
- * live process — stop it first, it respawns under the new identity. Purges the OLD
851
- * identity-keyed lifecycle markers (remove-parity) so a future namesake can't inherit
852
- * them. Does NOT move memory: the operativka folder + author/index attribution are
853
- * personality-keyed (the memory provider re-keys them separately); native-memory is
854
- * cwd-keyed and rides the kept cwd unchanged.
850
+ * FULL folder rename of a peer (Arthur's invariant: personality == normalize(basename(cwd)),
851
+ * a self-healed mirror — so a rename MUST move the folder, or profile-standard self-heal
852
+ * reverts the personality back to the basename). Atomic core, best-effort side-effects:
853
+ * 1. mv the cwd folder oldCwd → dirname(oldCwd)/<new> (the per-cwd profile, .mcp.json,
854
+ * native-memory, CLAUDE.md, .git all ride it).
855
+ * 2. mv the claude transcript slug dir (~/.claude/projects/<oldSlug> → <newSlug>) — keyed
856
+ * by realpath(cwd), so this is how the claude history is NOT orphaned. (codex history
857
+ * is keyed by the cwd recorded INSIDE each ~/.codex/sessions jsonl, not a path-dir, so
858
+ * it does NOT carry — a documented limitation; codex starts fresh sessions at the new cwd.)
859
+ * 3. renamePeer(…, newCwd): atomic registry personality+cwd + per-cwd profile personality.
860
+ * On any failure in 1-3 the fs moves are rolled back (folder + transcript back). Then
861
+ * best-effort: codex re-trust the new cwd + clean the old, re-write the claude .mcp.json
862
+ * identity fallback, purge the old identity's lifecycle markers. Refuses a LIVE peer unless
863
+ * --force. Does NOT touch memory (operativka/author/index) — the provider re-keys that
864
+ * separately AFTER this rename; deliberately NO `remove`/unprovision (which would risk a
865
+ * memory purge). `claudeProjectsDir` is a test seam (default ~/.claude/projects).
855
866
  */
856
867
  export async function renamePeerCli(
857
868
  oldPersonality: string,
858
869
  newPersonality: string,
859
- opts: CliEnvOptions & { force?: boolean } = {},
870
+ opts: CliEnvOptions & { force?: boolean; claudeProjectsDir?: string } = {},
860
871
  ): Promise<RenameOutcome> {
861
872
  const env = opts.env ?? process.env
862
873
  const index = readPeersIndex({ env })
@@ -865,6 +876,11 @@ export async function renamePeerCli(
865
876
  if (findPeer(index, newPersonality)) {
866
877
  return { oldPersonality, newPersonality, action: 'target-exists', reason: `"${newPersonality}" already exists` }
867
878
  }
879
+ const oldCwd = peer.cwd
880
+ const newCwd = join(dirname(oldCwd), newPersonality)
881
+ if (existsSync(newCwd)) {
882
+ return { oldPersonality, newPersonality, action: 'target-cwd-exists', reason: `${newCwd} already exists — move or remove it first` }
883
+ }
868
884
  const cfg = loadLifecycleConfig(env)
869
885
  if (!opts.force) {
870
886
  const liveRt = peer.runtimes.find(rt => isPeerLive(rt, oldPersonality, cfg.sockDir))
@@ -873,13 +889,57 @@ export async function renamePeerCli(
873
889
  oldPersonality,
874
890
  newPersonality,
875
891
  action: 'refused-live',
876
- reason: `"${oldPersonality}" is LIVE on ${liveRt} — its running session carries the old identity in its env; stop it first (\`iapeer stop ${oldPersonality}\`) or pass --force`,
892
+ reason: `"${oldPersonality}" is LIVE on ${liveRt} — its session + folder must be quiescent for the move; stop it first (\`iapeer stop ${oldPersonality}\`) or pass --force`,
877
893
  }
878
894
  }
879
895
  }
880
- await renamePeer(oldPersonality, newPersonality, { env })
896
+ // ── atomic core (1-3) with rollback ──────────────────────────────────────────
897
+ const projectsDir = opts.claudeProjectsDir ?? claudeProjectsRoot()
898
+ const oldSlug = transcriptSlug(oldCwd) // BEFORE the mv — realpath needs the live dir
899
+ const oldTx = join(projectsDir, oldSlug)
900
+ renameSync(oldCwd, newCwd) // 1. folder (atomic on one filesystem)
901
+ let transcriptMoved = false
902
+ let newTx: string | undefined
903
+ try {
904
+ const newSlug = transcriptSlug(newCwd) // AFTER the mv — realpath(newCwd) resolves
905
+ newTx = join(projectsDir, newSlug)
906
+ if (oldSlug !== newSlug && existsSync(oldTx) && !existsSync(newTx)) {
907
+ renameSync(oldTx, newTx) // 2. claude transcript slug dir
908
+ transcriptMoved = true
909
+ }
910
+ await renamePeer(oldPersonality, newPersonality, { env }, newCwd) // 3. atomic registry+profile
911
+ } catch (e) {
912
+ if (transcriptMoved && newTx) {
913
+ try { renameSync(newTx, oldTx) } catch { /* best-effort rollback */ }
914
+ }
915
+ try { renameSync(newCwd, oldCwd) } catch { /* best-effort rollback */ }
916
+ throw e
917
+ }
918
+ // ── best-effort side-effects (the core rename already committed) ──────────────
919
+ const sideEffects: string[] = []
920
+ if (peer.runtimes.includes('codex')) {
921
+ try {
922
+ const { preTrustCodexCwd, removeCodexCwdTrust } = await import('../launch/nativeMemory.ts')
923
+ preTrustCodexCwd(newCwd, env)
924
+ removeCodexCwdTrust(oldCwd, env)
925
+ const { removeCodexHooksTrustUnder } = await import('../launch/codexHooksTrust.ts')
926
+ removeCodexHooksTrustUnder(oldCwd, env)
927
+ sideEffects.push('codex-trust: re-trusted new cwd + cleaned old')
928
+ } catch (e) {
929
+ sideEffects.push(`codex-trust: failed (${e instanceof Error ? e.message : String(e)})`)
930
+ }
931
+ }
932
+ if (peer.runtimes.includes('claude')) {
933
+ try {
934
+ const { writeClaudeMcpConfig, resolveDaemonMcpUrl } = await import('../init/index.ts')
935
+ writeClaudeMcpConfig(newCwd, newPersonality, resolveDaemonMcpUrl({ env }))
936
+ sideEffects.push('claude .mcp.json: identity rewritten')
937
+ } catch (e) {
938
+ sideEffects.push(`claude .mcp.json: failed (${e instanceof Error ? e.message : String(e)})`)
939
+ }
940
+ }
881
941
  const purgedState = peer.runtimes.flatMap(rt => purgeIdentityState(cfg, buildProcessAddress(rt, oldPersonality)))
882
- return { oldPersonality, newPersonality, action: 'renamed', cwd: peer.cwd, purgedState }
942
+ return { oldPersonality, newPersonality, action: 'renamed', oldCwd, newCwd, transcriptMoved, purgedState, sideEffects }
883
943
  }
884
944
 
885
945
  // ─────────────────────────────────────────────────────────────────────────────
@@ -1054,7 +1114,7 @@ const VERBS: ReadonlyArray<{ sig: string; desc: string }> = [
1054
1114
  desc: 'LAZY soft-reload: agentic peer comes up FRESH (re-reads doctrine) on its NEXT natural wake — no kill, no burst-wake; non-agentic runtimes skipped. --all = whole fleet. (eager: `new`/self-fresh)',
1055
1115
  },
1056
1116
  { sig: 'remove <peer> [--force]', desc: 'delete a peer\'s registry record (locked writer); refuses a LIVE peer unless --force' },
1057
- { sig: 'rename <old> <new> [--force]', desc: 'rename a peer\'s personality (registry + per-cwd profile, atomic; cwd/transcript-history kept); refuses a LIVE peer unless --force. Memory re-key is the provider\'s separate step.' },
1117
+ { sig: 'rename <old> <new> [--force]', desc: 'rename a peer — moves the cwd folder + claude transcript + atomic registry/profile (personality = folder name); refuses a LIVE peer unless --force. Memory re-key is the provider\'s separate step.' },
1058
1118
  {
1059
1119
  sig: 'send <target> (--message <text> | --message-file <f|->) [--from <id>] [--attachment <p>]… [--topic <t>]',
1060
1120
  desc: 'manual IAP send (fallback)',
@@ -1759,19 +1819,23 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
1759
1819
  return o.action === 'refused-live' ? 1 : 0
1760
1820
  }
1761
1821
  case 'rename': {
1762
- // Rename a peer's personality (registry + per-cwd profile, atomic; cwd KEPT so
1763
- // the transcript history survives). Refuses a LIVE peer unless --force. Memory
1764
- // (operativka folder + author/index) is personality-keyed and re-keyed separately
1765
- // by the provider — say so, or it reads as a silent data loss.
1822
+ // FULL folder rename (Arthur's invariant: personality == basename(cwd)): mv the
1823
+ // folder + the claude transcript slug dir + atomic registry cwd+personality, with
1824
+ // rollback; best-effort codex re-trust + .mcp.json rewrite + old-marker purge.
1825
+ // Refuses a LIVE peer unless --force. Memory + codex-history caveats are stated —
1826
+ // a silent data move reads as loss otherwise.
1766
1827
  if (!positionals[0] || !positionals[1]) {
1767
1828
  return argErr(errOut, 'rename needs old and new names — usage: iapeer rename <old> <new> [--force]')
1768
1829
  }
1769
1830
  const o = await renamePeerCli(positionals[0], positionals[1], { force: flags.force === true, env })
1770
1831
  if (o.action === 'renamed') {
1771
- out(`renamed "${o.oldPersonality}" → "${o.newPersonality}" (registry + per-cwd profile; cwd kept: ${o.cwd})\n`)
1772
- if (o.purgedState?.length) out(`lifecycle state purged (old identity): ${o.purgedState.join(', ')}\n`)
1832
+ out(`renamed "${o.oldPersonality}" → "${o.newPersonality}"\n`)
1833
+ out(` folder: ${o.oldCwd} → ${o.newCwd}\n`)
1834
+ out(` claude transcript: ${o.transcriptMoved ? 'moved (history preserved)' : 'no slug dir to move'}\n`)
1835
+ if (o.sideEffects?.length) out(` ${o.sideEffects.join('\n ')}\n`)
1836
+ if (o.purgedState?.length) out(` lifecycle state purged (old identity): ${o.purgedState.join(', ')}\n`)
1773
1837
  out(
1774
- `NOTE: memory is NOT moved by rename — re-key the operativka folder + author/index attribution under "${o.newPersonality}" via the memory provider (native-memory rides the kept cwd, unchanged). Wake "${o.newPersonality}" to verify identity + history.\n`,
1838
+ `NOTE: memory (operativka folder + author/index) is personality-keyed — re-key it under "${o.newPersonality}" via the memory provider separately. codex resume-history for the old cwd does NOT carry (codex keys sessions on the in-file cwd). Wake "${o.newPersonality}" to verify identity + history.\n`,
1775
1839
  )
1776
1840
  } else if (o.action === 'absent') errOut(`rename: "${o.oldPersonality}" not registered\n`)
1777
1841
  else errOut(`rename: ${o.reason}\n`)
@@ -634,6 +634,7 @@ export async function renamePeer(
634
634
  oldPersonality: string,
635
635
  newPersonality: string,
636
636
  options: PeersUpdateOptions = {},
637
+ newCwd?: string,
637
638
  ): Promise<PeersIndex> {
638
639
  if (!isValidName(oldPersonality) || !isValidName(newPersonality)) {
639
640
  throw new IapError('peers rename requires valid personalities')
@@ -647,22 +648,30 @@ export async function renamePeer(
647
648
  if (index.peers.some(peer => peer.personality === newPersonality)) {
648
649
  throw new IapError(`peer "${newPersonality}" already exists`)
649
650
  }
650
- const sourceProfile = readPeerProfile(target.cwd)
651
+ // newCwd: the FOLDER was already moved by the caller (the cwd-move rename), so the
652
+ // profile now lives at newCwd; read/rewrite it THERE and update the registry cwd
653
+ // pointer atomically with the personality. Omitted → keep-cwd rename (profile at the
654
+ // registry's current cwd). The folder mv + transcript mv are the caller's side (cli),
655
+ // done BEFORE this atomic registry write (with rollback on failure).
656
+ const profileCwd = newCwd ?? target.cwd
657
+ const sourceProfile = readPeerProfile(profileCwd)
651
658
  if (!sourceProfile) {
652
659
  throw new IapError(
653
- `peer "${oldPersonality}" cwd ${target.cwd} has no ${IAPEER_DIR}/${PEER_PROFILE_FILE}; restore the cwd or remove the registry entry`,
660
+ `peer "${oldPersonality}" cwd ${profileCwd} has no ${IAPEER_DIR}/${PEER_PROFILE_FILE}; restore the cwd or remove the registry entry`,
654
661
  )
655
662
  }
656
663
  if (sourceProfile.personality !== oldPersonality) {
657
664
  throw new IapError(
658
- `peer "${oldPersonality}" cwd ${target.cwd} profile has personality "${sourceProfile.personality}", not "${oldPersonality}"; registry out of sync`,
665
+ `peer "${oldPersonality}" cwd ${profileCwd} profile has personality "${sourceProfile.personality}", not "${oldPersonality}"; registry out of sync`,
659
666
  )
660
667
  }
661
- writePeerProfileAtomic(target.cwd, { ...sourceProfile, personality: newPersonality })
668
+ writePeerProfileAtomic(profileCwd, { ...sourceProfile, personality: newPersonality })
662
669
  return {
663
670
  ...index,
664
671
  peers: index.peers.map(peer =>
665
- peer.personality === oldPersonality ? { ...peer, personality: newPersonality } : peer,
672
+ peer.personality === oldPersonality
673
+ ? { ...peer, personality: newPersonality, ...(newCwd ? { cwd: newCwd } : {}) }
674
+ : peer,
666
675
  ),
667
676
  }
668
677
  }, options)
@@ -89,7 +89,7 @@ function anyBootDialog(pane: string): boolean {
89
89
  * mkdtemp temp cwd. realpath first so a symlinked cwd maps to the dir claude
90
90
  * actually wrote (a stale path falls through to the original string).
91
91
  */
92
- function transcriptSlug(workDir: string): string {
92
+ export function transcriptSlug(workDir: string): string {
93
93
  let phys = workDir
94
94
  try {
95
95
  phys = realpathSync(workDir)
@@ -99,8 +99,15 @@ function transcriptSlug(workDir: string): string {
99
99
  return phys.replace(/[^a-zA-Z0-9]/g, '-')
100
100
  }
101
101
 
102
- function transcriptDir(workDir: string): string {
103
- return join(homedir(), '.claude', 'projects', transcriptSlug(workDir))
102
+ /** The base dir holding all claude project transcript slugs (~/.claude/projects).
103
+ * Exported so a cwd-move (peer folder rename) can relocate the slug dir from the
104
+ * SINGLE source of the slug encoding, never a re-implementation that could drift. */
105
+ export function claudeProjectsRoot(): string {
106
+ return join(homedir(), '.claude', 'projects')
107
+ }
108
+
109
+ export function transcriptDir(workDir: string): string {
110
+ return join(claudeProjectsRoot(), transcriptSlug(workDir))
104
111
  }
105
112
 
106
113
  // ─────────────────────────────────────────────────────────────────────────────