@@ -68,6 +68,100 @@ func getDeniedList() []string {
6868// agentPool is the shared agent pool for /swarm command.
6969var agentPool * agent.Pool
7070
71+ // ---------------------------------------------------------------------------
72+ // Undo stack — snapshots of file contents before agent writes
73+ // ---------------------------------------------------------------------------
74+
75+ // undoSnapshot records the state of a single file before an agent modification.
76+ type undoSnapshot struct {
77+ Path string
78+ Content []byte // nil means the file did not exist (new file created)
79+ }
80+
81+ // undoFrame is one agent turn's worth of file modifications.
82+ type undoFrame []undoSnapshot
83+
84+ var (
85+ undoStack []undoFrame
86+ undoStackMu sync.Mutex
87+ // currentFrame accumulates snapshots for the in-progress turn.
88+ currentFrame undoFrame
89+ )
90+
91+ // beginUndoFrame starts a new undo frame for the current agent turn.
92+ func beginUndoFrame () {
93+ undoStackMu .Lock ()
94+ defer undoStackMu .Unlock ()
95+ currentFrame = undoFrame {}
96+ }
97+
98+ // commitUndoFrame pushes the current frame (if non-empty) onto the stack.
99+ func commitUndoFrame () {
100+ undoStackMu .Lock ()
101+ defer undoStackMu .Unlock ()
102+ if len (currentFrame ) > 0 {
103+ undoStack = append (undoStack , currentFrame )
104+ currentFrame = nil
105+ }
106+ }
107+
108+ // captureFileSnapshot saves the current content of path into the active frame.
109+ // Called just before a write/edit tool overwrites the file.
110+ func captureFileSnapshot (path string ) {
111+ undoStackMu .Lock ()
112+ defer undoStackMu .Unlock ()
113+ // Only capture once per path per frame.
114+ for _ , s := range currentFrame {
115+ if s .Path == path {
116+ return
117+ }
118+ }
119+ content , err := os .ReadFile (path )
120+ if os .IsNotExist (err ) {
121+ currentFrame = append (currentFrame , undoSnapshot {Path : path , Content : nil })
122+ } else if err == nil {
123+ currentFrame = append (currentFrame , undoSnapshot {Path : path , Content : content })
124+ }
125+ }
126+
127+ // performUndo restores the most recent undo frame.
128+ // Returns the list of restored paths, or an error.
129+ func performUndo () ([]string , error ) {
130+ undoStackMu .Lock ()
131+ defer undoStackMu .Unlock ()
132+ if len (undoStack ) == 0 {
133+ return nil , fmt .Errorf ("nothing to undo" )
134+ }
135+ frame := undoStack [len (undoStack )- 1 ]
136+ undoStack = undoStack [:len (undoStack )- 1 ]
137+
138+ var restored []string
139+ var errs []string
140+ for _ , snap := range frame {
141+ if snap .Content == nil {
142+ // File was newly created — remove it.
143+ if err := os .Remove (snap .Path ); err != nil && ! os .IsNotExist (err ) {
144+ errs = append (errs , fmt .Sprintf ("remove %s: %v" , snap .Path , err ))
145+ continue
146+ }
147+ } else {
148+ if err := os .MkdirAll (filepath .Dir (snap .Path ), 0o755 ); err != nil {
149+ errs = append (errs , fmt .Sprintf ("mkdir %s: %v" , snap .Path , err ))
150+ continue
151+ }
152+ if err := os .WriteFile (snap .Path , snap .Content , 0o644 ); err != nil {
153+ errs = append (errs , fmt .Sprintf ("restore %s: %v" , snap .Path , err ))
154+ continue
155+ }
156+ }
157+ restored = append (restored , snap .Path )
158+ }
159+ if len (errs ) > 0 {
160+ return restored , fmt .Errorf ("%s" , strings .Join (errs , "; " ))
161+ }
162+ return restored , nil
163+ }
164+
71165// wrapToolsWithPermissions wraps tools that need approval in safe mode
72166// and adds audit logging to all tools.
73167func wrapToolsWithPermissions (tools []iteragent.Tool ) []iteragent.Tool {
@@ -84,6 +178,13 @@ func wrapToolsWithPermissions(tools []iteragent.Tool) []iteragent.Tool {
84178
85179 trackSessionChanges (t .Name , args )
86180
181+ // Capture file snapshot before any write/edit so /undo can restore.
182+ if t .Name == "write_file" || t .Name == "edit_file" || t .Name == "create_file" {
183+ if p , ok := args ["path" ]; ok {
184+ captureFileSnapshot (p )
185+ }
186+ }
187+
87188 if denied := checkToolDirPermission (cfg , t .Name , args ); denied != "" {
88189 logAudit (t .Name , auditArgs , "DENIED (dir restriction)" )
89190 return denied , nil
0 commit comments