@agfpd/iapeer 0.4.16 → 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,35 @@ 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
+
30
+ ## [0.4.17] - 2026-06-21
31
+
32
+ ### Added
33
+
34
+ - `iapeer rename <old> <new>` — first-class peer-identity rename (parity with remove/create).
35
+ Renames the personality in **both** the registry and the per-cwd profile atomically and
36
+ **keeps the cwd**, so the claude transcript history (keyed by `realpath(cwd)`) survives.
37
+ Refuses a LIVE peer unless `--force` (a running session carries the old identity in its
38
+ env), and purges the old identity's lifecycle markers. Memory re-key (the personality-keyed
39
+ operativka folder + author/index attribution) is the memory provider's separate step;
40
+ native-memory is cwd-keyed and unaffected.
41
+
13
42
  ## [0.4.16] - 2026-06-21
14
43
 
15
44
  ### Fixed
@@ -245,7 +274,9 @@ Initial public release — the foundation core of the iapeer multi-agent ecosyst
245
274
  - **Heterogeneous peers** — AI agents (Claude, Codex), humans (Telegram), and services
246
275
  (timer = cron, watcher = event) are all first-class and addressed the same way.
247
276
 
248
- [Unreleased]: https://github.com/agfpd/iapeer/compare/v0.4.16...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
279
+ [0.4.17]: https://github.com/agfpd/iapeer/compare/v0.4.16...v0.4.17
249
280
  [0.4.16]: https://github.com/agfpd/iapeer/compare/v0.4.15...v0.4.16
250
281
  [0.4.15]: https://github.com/agfpd/iapeer/compare/v0.4.14...v0.4.15
251
282
  [0.4.14]: https://github.com/agfpd/iapeer/compare/v0.4.13...v0.4.14
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.4.16",
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 {
@@ -53,7 +53,8 @@ import { getAdapter } from '../launch/index.ts'
53
53
  import { hostRunDir } from '../launch/ptyHost.ts' // pty-only: attach via the supervisor client
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
- import { readPeerProfile, resolveCallerIdentity, resolveIdentity, writePeerProfileAtomic } from '../identity/index.ts'
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,
@@ -827,6 +828,120 @@ export async function removePeerCli(
827
828
  }
828
829
  }
829
830
 
831
+ // ─────────────────────────────────────────────────────────────────────────────
832
+ // rename — first-class peer-identity rename (parity with remove/create). Wraps
833
+ // renamePeer (registry + per-cwd profile, atomic, inside the lock) and KEEPS the
834
+ // cwd, so the claude transcript history (slug = realpath(cwd)) survives the rename.
835
+ // ─────────────────────────────────────────────────────────────────────────────
836
+
837
+ export interface RenameOutcome {
838
+ oldPersonality: string
839
+ newPersonality: string
840
+ action: 'renamed' | 'absent' | 'target-exists' | 'target-cwd-exists' | 'refused-live'
841
+ reason?: string
842
+ oldCwd?: string
843
+ newCwd?: string
844
+ transcriptMoved?: boolean
845
+ purgedState?: string[]
846
+ sideEffects?: string[]
847
+ }
848
+
849
+ /**
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).
866
+ */
867
+ export async function renamePeerCli(
868
+ oldPersonality: string,
869
+ newPersonality: string,
870
+ opts: CliEnvOptions & { force?: boolean; claudeProjectsDir?: string } = {},
871
+ ): Promise<RenameOutcome> {
872
+ const env = opts.env ?? process.env
873
+ const index = readPeersIndex({ env })
874
+ const peer = findPeer(index, oldPersonality)
875
+ if (!peer) return { oldPersonality, newPersonality, action: 'absent' }
876
+ if (findPeer(index, newPersonality)) {
877
+ return { oldPersonality, newPersonality, action: 'target-exists', reason: `"${newPersonality}" already exists` }
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
+ }
884
+ const cfg = loadLifecycleConfig(env)
885
+ if (!opts.force) {
886
+ const liveRt = peer.runtimes.find(rt => isPeerLive(rt, oldPersonality, cfg.sockDir))
887
+ if (liveRt) {
888
+ return {
889
+ oldPersonality,
890
+ newPersonality,
891
+ action: 'refused-live',
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`,
893
+ }
894
+ }
895
+ }
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
+ }
941
+ const purgedState = peer.runtimes.flatMap(rt => purgeIdentityState(cfg, buildProcessAddress(rt, oldPersonality)))
942
+ return { oldPersonality, newPersonality, action: 'renamed', oldCwd, newCwd, transcriptMoved, purgedState, sideEffects }
943
+ }
944
+
830
945
  // ─────────────────────────────────────────────────────────────────────────────
831
946
  // send — manual IAP send fallback (contract Примитивы §send). Goes through the
832
947
  // same router path as send_to_peer (resolve → deliver / wake), in-process so it
@@ -999,6 +1114,7 @@ const VERBS: ReadonlyArray<{ sig: string; desc: string }> = [
999
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)',
1000
1115
  },
1001
1116
  { sig: 'remove <peer> [--force]', desc: 'delete a peer\'s registry record (locked writer); refuses a LIVE peer unless --force' },
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.' },
1002
1118
  {
1003
1119
  sig: 'send <target> (--message <text> | --message-file <f|->) [--from <id>] [--attachment <p>]… [--topic <t>]',
1004
1120
  desc: 'manual IAP send (fallback)',
@@ -1702,6 +1818,29 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
1702
1818
  else errOut(`remove: ${o.reason}\n`)
1703
1819
  return o.action === 'refused-live' ? 1 : 0
1704
1820
  }
1821
+ case 'rename': {
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.
1827
+ if (!positionals[0] || !positionals[1]) {
1828
+ return argErr(errOut, 'rename needs old and new names — usage: iapeer rename <old> <new> [--force]')
1829
+ }
1830
+ const o = await renamePeerCli(positionals[0], positionals[1], { force: flags.force === true, env })
1831
+ if (o.action === 'renamed') {
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`)
1837
+ out(
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`,
1839
+ )
1840
+ } else if (o.action === 'absent') errOut(`rename: "${o.oldPersonality}" not registered\n`)
1841
+ else errOut(`rename: ${o.reason}\n`)
1842
+ return o.action === 'renamed' ? 0 : 1
1843
+ }
1705
1844
  case 'send': {
1706
1845
  // Message body from EITHER --message <text> OR --message-file <f> (f='-' →
1707
1846
  // stdin). The runtime packages (telegram/notifier) + monitor deliver via
@@ -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
  // ─────────────────────────────────────────────────────────────────────────────