&1","r")
if not h then cprint(color.red,"!!! Restore failed to start") return false end
for line in h:lines() do
status(color.orange,line)
end
local ok,_,code=h:close()
if code~=0 then cprint(color.red,"!!! Restore failed (exit "..tostring(code)..")") return false end
status(color.orange,"restored backup #"..idx.." ("..backupdisplayname(name).."); quitting. start Talos again to continue.")
return true
end
function diffbackup(n)
local names=listbackups()
if #names==0 then status(color.orange,"no backups") return end
local idx
if n==nil then idx=#names else idx=tonumber(n) end
if not idx then status(color.orange,"diff which backup number? run /backup list and choose a number from there") return end
local name=names[idx]
if not name then status(color.orange,"no backup #"..tostring(n).."; run /backup list and choose a number from there") return end
local tmp=home.."diff-tmp"
local ok,err=rmtree(tmp,false)
if not ok then status(color.red,err) return end
ok,err=mkdirp(tmp.."/backup")
if not ok then status(color.red,err) return end
local archive=backuppath(name)
local cmdline=[[tar -xf ]]..shellquote(archive)..[[ -C ]]..shellquote(tmp.."/backup")..[[ && diff --color=always -ruN --exclude=.talos ]]..shellquote(tmp.."/backup")..[[ .]]
local h=io.popen(cmdline.." 2>&1","r")
if not h then rmtree(tmp,false) status(color.red,"diff failed to start") return end
local sawoutput=false
local finalnl=true
while true do
local chunk=h:read(8192)
if not chunk then break end
if not sawoutput then
cprint(color.orange,"--- diff against backup #"..idx.." ("..name..") ---")
sawoutput=true
end
finalnl=string.sub(chunk,-1)=="\n"
io.write(chunk)
end
local _,_,code=h:close()
rmtree(tmp,false)
if sawoutput then
if not finalnl then print() end
cprint(color.orange,"--- end of diff ---")
end
if code==0 and not sawoutput then
status(color.orange,"no changes since backup #"..idx.." ("..name..")")
elseif code~=0 and code~=1 then
status(color.red,"diff failed (exit "..tostring(code)..")")
end
end
--==## UPLOAD/DOWNLOAD SERVERS ##==--
function htmlescape(s)
return tostring(s or ""):gsub("&","&"):gsub("<","<"):gsub(">",">"):gsub('"',""")
end
function httpresponse(client,code,ctype,body)
body=body or ""
client:send("HTTP/1.1 "..code.."\r\n")
client:send("Content-Type: "..ctype.."\r\n")
client:send("Content-Length: "..#body.."\r\n")
client:send("Connection: close\r\n\r\n")
client:send(body)
end
function uploadpage(dir,msg)
local note=msg and (""..htmlescape(msg).."
") or ""
return [==[Talos uploadTalos upload
Destination: ]==]..htmlescape(dir)..[==[
]==]..note..[==[
]==]
end
function uploadfilename(name)
name=tostring(name or "")
name=string.gsub(name,"\\","/")
name=string.match(name,"[^/]+$") or ""
name=string.gsub(name,"[%c/]","")
name=string.gsub(name,"^%s+","")
name=string.gsub(name,"%s+$","")
if name=="" or name=="." or name==".." then name="upload.bin" end
return name
end
function uniquefile(dir,name)
local path=dir.."/"..name
if not lfs.attributes(path) then return path,name end
local base,ext=string.match(name,"^(.*)(%.[^%.]*)$")
if not base or base=="" then base=name ext="" end
for i=2,10000 do
local candidate=base.." ("..i..")"..ext
path=dir.."/"..candidate
if not lfs.attributes(path) then return path,candidate end
end
return nil,nil
end
function parsemultipart(body,boundary,dir)
local saved={}
local errors={}
local sep="--"..boundary
local pos=string.find(body,sep,1,true)
while pos do
pos=pos+#sep
if string.sub(body,pos,pos+1)=="--" then break end
if string.sub(body,pos,pos+1)=="\r\n" then pos=pos+2 end
local hend=string.find(body,"\r\n\r\n",pos,true)
if not hend then break end
local headers=string.sub(body,pos,hend-1)
local datastart=hend+4
local nextsep=string.find(body,"\r\n"..sep,datastart,true)
if not nextsep then break end
local data=string.sub(body,datastart,nextsep-1)
local filename=string.match(headers,'filename="([^"]*)"')
if filename and filename~="" then
filename=uploadfilename(filename)
local path,final=uniquefile(dir,filename)
if path then
local pok,perr=protectregularwrite(path)
if pok then
local ok,err=writefile(path,data)
if ok then table.insert(saved,final) else table.insert(errors,final..": "..tostring(err)) end
else
table.insert(errors,filename..": "..tostring(perr))
end
else
table.insert(errors,filename..": could not choose unique filename")
end
end
pos=string.find(body,sep,nextsep+2,true)
end
return saved,errors
end
function readhttprequest(client)
local req=client:receive("*l")
if not req then return nil end
req=string.gsub(req,"\r$","")
local method,path=string.match(req,"^(%S+)%s+(%S+)")
if not method then return nil,nil,nil,nil,"malformed request line" end
local headers={}
while true do
local line=client:receive("*l")
if not line then return nil end
line=string.gsub(line,"\r$","")
if line=="" then break end
local k,v=string.match(line,"^([^:]+):%s*(.*)$")
if k then headers[string.lower(k)]=v end
end
local rawlen=headers["content-length"]
local len=tonumber(rawlen or "0")
if not len or len<0 or len%1~=0 then
return method,path,headers,nil,"invalid content-length"
end
if len>uploadmaxbytes then
return method,path,headers,nil,"request body too large; limit is "..humansize(uploadmaxbytes)
end
local chunks={}
local got=0
while got65535 or port%1~=0 then
status(color.orange,"usage: /upload PORT DIRECTORY")
return
end
if not rel or rel=="" then
status(color.orange,"usage: /upload PORT DIRECTORY")
return
end
local dir,err=toolpath(rel)
if not dir then status(color.red,err) return end
local ok
ok,err=mkdirp(dir)
if not ok then status(color.red,err) return end
local server,serr=socket.bind("127.0.0.1",port)
if not server then status(color.red,"upload server failed: "..tostring(serr)) return end
server:settimeout(1)
status(color.orange,"upload server listening on http://127.0.0.1:"..port.."/ -> "..rel)
local stop=false
while not stop do
local client=server:accept()
if client then
client:settimeout(30)
local method,path,headers,body,rerr=readhttprequest(client)
if rerr then
local code=string.match(rerr,"too large") and "413 Payload Too Large" or "400 Bad Request"
httpresponse(client,code,"text/plain; charset=utf-8",rerr)
elseif not method then
client:close()
elseif path=="/stop" then
stop=true
httpresponse(client,"200 OK","text/html; charset=utf-8",uploadpage(rel,"Upload server stopped."))
elseif method=="POST" and path=="/upload" then
local ctype=headers["content-type"] or ""
local boundary=string.match(ctype,"boundary=([^;]+)")
if boundary then
boundary=string.gsub(boundary,'^"(.*)"$',"%1")
local saved,errors=parsemultipart(body,boundary,dir)
local msg=(#saved==0) and "No files uploaded." or ("Uploaded: "..table.concat(saved,", "))
if #errors>0 then msg=msg.." Errors: "..table.concat(errors,"; ") end
status(#errors>0 and color.red or color.orange,msg)
httpresponse(client,#errors>0 and "500 Internal Server Error" or "200 OK","text/html; charset=utf-8",uploadpage(rel,msg))
else
httpresponse(client,"400 Bad Request","text/plain; charset=utf-8","missing multipart boundary")
end
else
httpresponse(client,"200 OK","text/html; charset=utf-8",uploadpage(rel))
end
client:close()
end
end
server:close()
status(color.orange,"upload server stopped")
end
function downloadpage(rel,msg)
local note=msg and (""..htmlescape(msg).."
") or ""
return [==[Talos downloadTalos download
File: ]==]..htmlescape(rel)..[==[
]==]..note..[==[
Download file
]==]
end
function downloadserver(port,rel)
port=tonumber(port)
if not port or port<1 or port>65535 or port%1~=0 then
status(color.orange,"usage: /download PORT FILE")
return
end
if not rel or rel=="" then
status(color.orange,"usage: /download PORT FILE")
return
end
local path,err=toolpath(rel)
if not path then status(color.red,err) return end
local attr=lfs.attributes(path)
if not attr then status(color.red,"error: no such file") return end
if attr.mode~="file" then status(color.red,"error: path is not a regular file") return end
local server,serr=socket.bind("127.0.0.1",port)
if not server then status(color.red,"download server failed: "..tostring(serr)) return end
server:settimeout(1)
status(color.orange,"download server listening on http://127.0.0.1:"..port.."/ -> "..rel)
local stop=false
while not stop do
local client=server:accept()
if client then
client:settimeout(30)
local method,reqpath,headers,body,rerr=readhttprequest(client)
if rerr then
local code=string.match(rerr,"too large") and "413 Payload Too Large" or "400 Bad Request"
httpresponse(client,code,"text/plain; charset=utf-8",rerr)
elseif not method then
client:close()
elseif reqpath=="/stop" then
stop=true
httpresponse(client,"200 OK","text/html; charset=utf-8",downloadpage(rel,"Download server stopped."))
elseif method=="GET" and (reqpath=="/" or reqpath=="") then
httpresponse(client,"200 OK","text/html; charset=utf-8",downloadpage(rel))
elseif method=="GET" and string.match(reqpath,"^/file%??") then
local h,err=io.open(path,"rb")
if not h then
httpresponse(client,"500 Internal Server Error","text/plain; charset=utf-8",err or "failed to read file")
else
local filename=uploadfilename(rel)
client:send("HTTP/1.1 200 OK\r\n")
client:send("Content-Type: application/octet-stream\r\n")
client:send("Content-Length: "..attr.size.."\r\n")
client:send("Content-Disposition: attachment; filename=\""..string.gsub(filename,'"','').."\"\r\n")
client:send("Connection: close\r\n\r\n")
local sent=0
while true do
local chunk=h:read(64*1024)
if not chunk then break end
local ok=client:send(chunk)
if not ok then break end
sent=sent+#chunk
end
h:close()
if sent==attr.size then status(color.orange,"downloaded "..rel.." ("..humansize(sent)..")") end
end
else
httpresponse(client,"404 Not Found","text/plain; charset=utf-8","not found")
end
client:close()
end
end
server:close()
status(color.orange,"download server stopped")
end
--==## HELP AND COMMANDS ##==--
function printhelp()
cprint(color.blue,[==[
Talos - interactive LLM agent
=============================
Talos is a command-line AI agent. Type a message at the prompt and press Enter to talk to it. Talos can reason, hold a conversation, and act on your working directory by calling tools (reading/writing files, running shell commands, searching, and more). It remembers context across sessions.
Copyright 2026 Tritonio
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, version 3. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
GETTING STARTED
- Just type and press Enter to send a message.
- Press Enter on an empty line to quit.
- For multiline input, start with Lua long-bracket syntax like [[, [=[, or [===[. Any number of equal signs is acceptable, as long as the closing marker matches: ]], ]=], or ]===]. The markers are stripped, and everything between them is sent as one message.
- Start with an optional session name: talos mysession
Each named session keeps separate history and memory under .talos/session-/. With no name, .talos/ is used.
WHAT TALOS CAN DO
- Hold a conversation and answer questions.
- Read, create, delete, search, and manipulate files in the working dir.
- Run sandboxed shell commands, and unsandboxed shell commands with your approval.
- Automatically resummarize the conversation if it grows too long, compressing messages into it's persistent memory.
SHELL COMMAND SAFETY
Normal run_shell commands are sandboxed inside the working directory]==]..(sharesandboxnet and "" or " and have no network")..[==[. Unsandboxed run_shell commands can access host paths]==]..(sharesandboxnet and "" or "/network")..[==[ and require per-command approval or exact pre-approval:
y / yes - run it once and show output to Talos
n / no - decline
a / always - run and pre-approve this exact command
p / preview - run it, show you the output, then ask whether Talos may see the output
h / help - explain the options
:reason - decline, and send reason to Talos
Commands are killed if they run longer than ]==]..shelltimeout..[==[ seconds.
PATH SAFETY
All file tools are confined to the working directory. Absolute paths and paths that escape the directory (via ..) are rejected. Only unsandboxed shell commands should be able to escape the working directory, and those need your approval to run.
SLASH COMMANDS
General
/help Show this guide.
/test Run live API self-tests.
/leaks Print a sandbox leak report locally; the report is not sent to the model.
Backups
/backup Write a compressed tar archive to .talos/backups/. Backups include everything in your working directory, including Talos's state.
Talos also creates an automatic backup before conversation turns if the newest backup is over 30 minutes old.
/backup list List backups from oldest to newest.
/backup prune Delete half the backups while keeping the latest backup, then show total backup size.
/backup restore N Restore backup number N, after making a safety backup, then quit.
/diff [N] Show a unified diff between the current workspace and backup N, or the latest backup.
Sandbox mounts
/mount list List persistent sandbox mounts.
/mount ro SRC DST Mount host SRC read-only at sandbox DST.
/mount rw SRC DST Mount host SRC read-write at sandbox DST.
/mount tmpfs DST Mount temporary writable memory at sandbox DST.
/mount remove N Remove sandbox mount number N.
Skills
/skill list List configured skill search paths and discovered skills.
/skill add PATH Add a skill search path with symlinks blocked. Defaults are .agents/skills/ and ~/.agents/skills/.
/skill addsl PATH Add a trusted skill search path with symlinks allowed.
/skill remove N Remove configured skill search path number N.
Memory and history
/summarize Fold older messages into long-term memory now.
/summarize all Fold all messages into long-term memory now.
/memory Print the current long-term memory.
/clear messages Erase the conversation history.
/clear memory Erase the long-term memory.
/clear cost Reset the session's cost.
/clear pre-approved Erase pre-approved shell commands.
/clear home Erase the persistent sandbox home directory.
/clear all Erase messages, memory, cost, pre-approved commands, and sandbox home.
Transfer
/upload PORT DIR Open a temporary browser upload page on PORT that saves files under DIR; use the page's stop button to end the command.
/download PORT FILE Open a temporary browser download page on PORT that serves FILE; use the page's stop button to end the command.
COST
Each session tracks its API cost, shown after responses and on startup.]==])
print()
end
function splitargv(s)
local argv={}
local buf={}
local quote=nil
local esc=false
local inarg=false
for i=1,#s do
local ch=string.sub(s,i,i)
if esc then
table.insert(buf,ch)
esc=false
inarg=true
elseif ch=="\\" then
esc=true
inarg=true
elseif quote then
if ch==quote then quote=nil else table.insert(buf,ch) end
inarg=true
elseif ch=="'" or ch=='"' then
quote=ch
inarg=true
elseif string.match(ch,"%s") then
if inarg then
table.insert(argv,table.concat(buf))
buf={}
inarg=false
end
else
table.insert(buf,ch)
inarg=true
end
end
if esc then table.insert(buf,"\\") end
if quote then return nil,"unterminated quote" end
if inarg then table.insert(argv,table.concat(buf)) end
return argv
end
function parsecommand(line)
local cmd,args=string.match(line,"^/(%S+)%s*(.*)$")
if not cmd then return nil end
local argv,err=splitargv(args or "")
if not argv then return cmd,nil,err end
return cmd,argv
end
function usage(text)
status(color.orange,"usage: "..text)
end
function runcommand(cmd,argv)
local f=commands[cmd]
if not f then status(color.orange,"unknown command; see /help") return end
return f(argv or {})
end
commands={
clear=function(argv)
local arg=argv[1]
local known=(arg=="all" or arg=="messages" or arg=="memory" or arg=="cost" or arg=="pre" or arg=="pre-approved" or arg=="preapproved" or arg=="home")
if #argv~=1 then usage("/clear messages|memory|cost|pre-approved|home|all") return end
if arg=="all" or arg=="messages" then messages={} savemessages() clearworkspace() status(color.orange,"cleared messages") end
if arg=="all" or arg=="memory" then memory="" savestate(home.."memory.txt",memory) status(color.orange,"cleared memory") end
if arg=="all" or arg=="cost" then sessioncost=0 savecost() status(color.orange,"cleared cost") end
if arg=="all" or arg=="pre" or arg=="pre-approved" or arg=="preapproved" then clearpreapproved() status(color.orange,"cleared pre-approved commands") end
if arg=="all" or arg=="home" then clearsandboxhome() status(color.orange,"cleared sandbox home") end
if not known then status(color.orange,"unknown clear target: "..tostring(arg).." (messages / memory / cost / pre-approved / home / all)") end
end,
summarize=function(argv)
if #argv>1 or (#argv==1 and argv[1]~="all") then usage("/summarize [all]") return end
if #messages==0 then status(color.orange,"nothing to summarize") else summarize(argv[1]=="all") end
end,
mount=function(argv)
if #argv==1 and argv[1]=="list" then printmounts()
elseif #argv==2 and argv[1]=="tmpfs" then addmount(argv[1],argv[2])
elseif #argv==3 and (argv[1]=="ro" or argv[1]=="rw") then addmount(argv[1],argv[2],argv[3])
elseif #argv==2 and argv[1]=="remove" then removemount(argv[2])
else usage("/mount list | /mount ro|rw SOURCE DEST | /mount tmpfs DEST | /mount remove N") end
end,
skill=function(argv)
if #argv==0 or (#argv==1 and argv[1]=="list") then printskills()
elseif #argv==2 and argv[1]=="add" then addskillpath(argv[2],false)
elseif #argv==2 and argv[1]=="addsl" then addskillpath(argv[2],true)
elseif #argv==2 and argv[1]=="remove" then removeskillpath(argv[2])
else usage("/skill [list] | /skill add PATH | /skill addsl PATH | /skill remove N") end
end,
leaks=function(argv)
if #argv~=0 then usage("/leaks") else leaks() end
end,
memory=function(argv)
if #argv~=0 then usage("/memory") return end
cprint(color.orange,"--- memory ---")
cprint(color.default,(memory=="" and "No memory." or memory))
cprint(color.orange,"--- end of memory ---")
end,
test=function(argv)
if #argv~=0 then usage("/test") else selftest() end
end,
backup=function(argv)
if #argv==0 then
local archive,err=makebackup()
if archive then
local bytes=lfs.attributes(archive,"size")
local name=string.match(archive,"([^/]+)$") or archive
name=backupdisplayname(name)
status(color.orange,"backup written: "..name..(bytes and (" ("..humansize(bytes)..")") or ""))
else
cprint(color.red,"!!! "..err)
end
elseif #argv==1 and argv[1]=="list" then printbackups()
elseif #argv==1 and argv[1]=="prune" then prunebackups()
elseif #argv==2 and argv[1]=="restore" then if restorebackup(argv[2]) then return "break" end
else usage("/backup [list|prune|restore N]") end
end,
diff=function(argv)
if #argv>1 then usage("/diff [N]") else diffbackup(argv[1]) end
end,
upload=function(argv)
if #argv~=2 then usage("/upload PORT DIR") else uploadserver(argv[1],argv[2]) end
end,
download=function(argv)
if #argv~=2 then usage("/download PORT FILE") else downloadserver(argv[1],argv[2]) end
end,
help=function(argv)
if #argv~=0 then usage("/help") else printhelp() end
end
}
--==## MAIN ##==--
checkdependencies()
updateresultclip()
local ok,err=mkdirp(home)
if not ok then io.stderr:write("Talos cannot create session directory: "..tostring(err).."\n") os.exit(1) end
claiminstance()
protecttalos()
memory=readfile(home.."memory.txt","")
loadworkspace()
loadmessages()
loadmounts()
loadignored()
loadskills()
repairmessagetail()
loadcost()
loadpreapproved()
cleansandboxtransient()
if #messages>0 then
cprint(color.orange,"--- previous conversation ---\n")
printhistory()
cprint(color.orange,"--- end of history ---\n")
else
printhelp()
end
if showsessioncost then status(color.gray,string.format("session cost: $%.2f",sessioncost)) end
print()
while true do
local line=readinput(sessionname.."> ")
if not line then break end --EOF: quit
--multiline input: if the line starts with [==[ (any number of = signs,
--including zero), keep reading until a line ends with the matching ]==].
local eqs=string.match(line,"^%[(=*)%[")
if eqs then
local close="]"..eqs.."]"
local body=string.sub(line,#eqs+3) --strip the opening [==[
local parts={body}
local eof=false
if string.sub(body,-#close)==close then
parts[1]=string.sub(body,1,#body-#close)
else
while true do
local more=readinput("*l")
if not more then eof=true break end --EOF mid-block: quit
if string.sub(more,-#close)==close then
table.insert(parts,string.sub(more,1,#more-#close))
break
end
table.insert(parts,more)
end
end
if eof then print() break end --separate, then quit
line=table.concat(parts,"\n")
end
print()
if line=="" then break end
skillcache=nil
local cmd,argv,err=parsecommand(line)
if err then
status(color.orange,err)
print()
elseif cmd then
if runcommand(cmd,argv)=="break" then break end
print()
else
maybeautobackup()
line=stripworkspacenotice(line)
local now=snapshotworkspace()
local notice=workspacenotice(workspacechanges(workspacestate,now))
if notice then line=line..notice end
table.insert(messages,{role="user",content=line})
savemessages()
runturn()
workspacestate=snapshotworkspace()
saveworkspace()
end
end
releaseinstance()